DEV Community

Cover image for Generic Constraints in Go: comparable, ~int, and the Trap of Underlying Types
Gabriel Anhaia
Gabriel Anhaia

Posted on

Generic Constraints in Go: comparable, ~int, and the Trap of Underlying Types


You write a tiny utility. A generic Min function. Two arguments, returns the smaller one. The first version uses any and the compiler rejects it because you cannot apply < to any. You switch to comparable and the compiler still rejects it, because comparable covers == and !=, not ordering. You finally land on cmp.Ordered and ship it. Then a teammate asks why their custom Score type compiles fine, but Min cannot call .String() on the value inside its body.

That last part is where most Go developers learn that constraints describe a set of types, not a class of objects. The tilde operator, the comparable keyword, and the cmp.Ordered interface in the standard library are three different ways to write down which types your function accepts, and each one has a sharp edge that does not show up until you try to use it on a real codebase.

What a constraint actually is

A type parameter constraint is an interface. The Go specification says so under type constraints: a constraint is "an interface that defines the set of permissible type arguments for the respective type parameter and controls the operations supported by values of that type parameter." Two operative words there. Set and operations.

The set part means you are not naming objects. You are naming types. The constraint int | int64 means the type argument must be exactly int or exactly int64. A custom type UserID int will not satisfy this constraint, because UserID is not int. It has the same memory layout, the same operations on the underlying type, and the compiler still rejects it.

That is what the tilde operator fixes.

The tilde: matching by underlying type

~int reads as "any type whose underlying type is int". Any named type defined as type Foo int is in the set, plus int itself. The Go spec covers this under interface type elements. The ~T form is an approximation element that admits all types with T as their underlying type.

Here is the version of Min you usually want to write:

package mymath

import "cmp"

func Min[T cmp.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}
Enter fullscreen mode Exit fullscreen mode

cmp.Ordered is defined in the standard cmp package as a constraint that admits the ordered numeric and string types and any type whose underlying type is one of them. The relevant declaration looks like this in the standard library:

type Ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
        ~uintptr |
        ~float32 | ~float64 |
        ~string
}
Enter fullscreen mode Exit fullscreen mode

Every term has a tilde. That is on purpose. It means a custom score, currency, or counter type can flow through Min without you adding it to the constraint by hand:

package score

import "mymath"

type Score int

func Pick(a, b Score) Score {
    return mymath.Min(a, b)
}
Enter fullscreen mode Exit fullscreen mode

The compiler accepts this. Score has underlying type int, int is one of the ordered terms in cmp.Ordered, and the tilde lets Score in. Without the tilde, you would need a constraint that listed every named integer type your codebase ever declared.

The trap: methods do not transfer

Now define a String() method on Score:

package score

import "fmt"

type Score int

func (s Score) String() string {
    return fmt.Sprintf("score(%d)", int(s))
}
Enter fullscreen mode Exit fullscreen mode

Add a tiny call site:

a := Score(7)
b := Score(3)
m := mymath.Min(a, b)
fmt.Println(m.String())
Enter fullscreen mode Exit fullscreen mode

This compiles, runs, and prints score(3). So far, no trap.

The trap shows up when you try to write the generic against an interface that calls String(). The constraint cmp.Ordered does not require a String() method, and inside Min, the type parameter T is constrained only to whatever cmp.Ordered says it is. You cannot call a.String() on the value of T inside the body of Min. The constraint set includes int, float64, and string. None of those have a String() method, so the compiler is right to reject it.

The version that does not compile:

func Min[T cmp.Ordered](a, b T) T {
    fmt.Println(a.String()) // a.String undefined
    if a < b {
        return a
    }
    return b
}
Enter fullscreen mode Exit fullscreen mode

The methods that exist on the named type are still there from outside the generic. A caller can take the returned Score and call .String() on it. Inside Min, the operations available are exactly the ones the constraint promises: ordering and assignment. Nothing more.

If you want both ordering and stringification, you have to ask for both. That means a new constraint that combines a type-set element with a method element:

type OrderedStringer interface {
    cmp.Ordered
    String() string
}
Enter fullscreen mode Exit fullscreen mode

This constraint has a problem the Go specification calls out plainly. A constraint with a non-empty type set and method elements is admissible. The type set restricts you to types whose underlying type is ordered AND that also have a String() method. int itself does not have a String() method, so plain int is no longer in the set. You have made the constraint stricter, and now Min[int] will fail to compile. You probably did not want that.

The right move, most of the time, is to stop trying to make one generic do two jobs. Order with cmp.Ordered. Format outside the generic. The methods on the named type are still there for the caller to use; they are invisible from inside the function.

When comparable is wrong, when any is wrong

The two most common constraint mistakes in code review are using any where comparable is needed, and using comparable where cmp.Ordered is needed. They show up at different points in the compile cycle.

any allows everything. The body of the function can do almost nothing with values of that type, because the operations available are the intersection of the operations on every type. You can pass them around, store them in variables, return them. You cannot compare them with ==, you cannot order them with <, you cannot index into them.

A function that needs == will not compile under any. A frequent shape:

func Index[T any](s []T, x T) int {
    for i, v := range s {
        if v == x { // invalid operation: v == x
            return i
        }
    }
    return -1
}
Enter fullscreen mode Exit fullscreen mode

Switching the constraint to comparable makes the body legal. comparable is the predeclared constraint covering all types that support == and !=. Most types satisfy it: integers, strings, structs of comparable fields, channels, pointers, interface values whose dynamic types are comparable. Slices, maps, and functions do not, because Go does not define == on them.

comparable is wrong, however, when you need ordering. <, <=, >, >= are not part of comparable. They live on the ordered numeric types and on string. That is what cmp.Ordered is for. The naming is the giveaway: comparable covers equality, ordered covers ordering. The two sets are different, and one is not a superset of the other. bool is comparable but not ordered. A struct is comparable if all its fields are, but it is never ordered.

The mental ladder when picking a constraint:

  • Do I only need to pass values around? any.
  • Do I need == or to use the value as a map key or set membership? comparable.
  • Do I need < and friends? cmp.Ordered.
  • Do I need a specific operation like + on numbers? Write a custom type set with the numeric terms and tildes.
  • Do I need a method? Add a method element to the constraint, accept that it shrinks the type set.

Custom type sets in practice

Sometimes the standard constraints are not enough. A clamp function for any signed numeric type, including custom named types, looks like this:

package mymath

type SignedNumber interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~float32 | ~float64
}

func Clamp[T SignedNumber](v, lo, hi T) T {
    if v < lo {
        return lo
    }
    if v > hi {
        return hi
    }
    return v
}
Enter fullscreen mode Exit fullscreen mode

Three things to notice. The constraint is an interface declaration, the same shape as any other interface in Go. The | separates terms in the type set. The ~ lets named types in. If you drop the tildes, a caller's type Celsius float64 will be rejected. If you drop the | form and try to use the constraint like a normal interface, the compiler will accept it at the constraint position but reject it as an ordinary interface type. Interfaces with type-set elements cannot be used as values. The Go spec calls these general interfaces and restricts them to constraint position.

Where to go next

The rest is reading the Go spec on type sets one paragraph at a time. The page that nails this down is general interfaces. It is short. It is worth half an hour the next time a generic refuses to compile and the error message looks like a wall.


If this was useful

Generics, type sets, the tilde, and the rules around method visibility inside constrained type parameters are covered with worked examples in The Complete Guide to Go Programming. If you have ever written a generic that compiled fine on its own, then refused to compile the moment a real caller tried to use it, the chapter on type parameters and constraint design is where the distinction between "the type's method set" and "what the constraint exposes" gets pinned down.

The companion book in the series, Hexagonal Architecture in Go, takes the same care to a higher layer: how to keep type parameters out of your domain types where they do not belong, and where to use them at the adapter layer where shared behaviour over many concrete types pays off.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)