Type Sets and Constraints

Go 1.18’s generics rest on a single conceptual move: an interface is redefined to specify a set of types, not merely a set of methods (Go spec, Interface types; An Introduction To Generics). Every interface now defines a type set — the set of all types that implement it — and a constraint is just an interface used to bound a type parameter, permitting exactly the types in its type set as type arguments. The pre-1.18 method-only interface is the special case where the type set is “all types whose method set includes these methods.” Go 1.18 added two new ways to write type-set elements: the ~T form (the underlying-type element: all types whose underlying type is T) and the | union (the union of several elements). It also added the predeclared comparable constraint. This reframing is what lets a constraint say “any type supporting <” or “any type with underlying type int” — things a method list alone could never express.

Mental Model

Before Go 1.18, an interface was a behavioral contract: “any type with these methods.” After Go 1.18, an interface is a set of types, and the behavioral reading becomes one way of describing such a set. interface{ String() string } describes the set “all types whose method set contains String() string.” interface{ ~int } describes the set “all types whose underlying type is int.” interface{ int | string } describes the set “exactly int and string.” All three are interfaces; all three define type sets; the first can be used both as a variable type and a constraint, while the latter two — because they restrict by type identity, not behavior — can only be used as constraints.

The reason a constraint enables operations falls out of this. If T is constrained to interface{ ~int | ~float64 }, the compiler knows T can only ever be an integer- or float-underlying type, and every type in that set supports +, <, *. So inside the generic body, those operators type-check. The constraint is the compiler’s proof obligation: “whatever T becomes, it is in this set, and this set supports these operations.”

flowchart TD
    I["interface { ... }"] --> TS["defines a TYPE SET"]
    TS --> E1["method element: String() string<br/>→ all types with that method"]
    TS --> E2["type term: int<br/>→ exactly the type int"]
    TS --> E3["underlying-type element: ~int<br/>→ all types whose underlying type is int"]
    TS --> E4["union: int | ~string | Float<br/>→ union of the elements' sets"]
    TS --> COMP["comparable<br/>→ all strictly-comparable types"]
    TS -. "used as constraint" .-> C["bounds a type parameter:<br/>type arg must be IN the set"]
    TS -. "method-only set used as" .-> V["ordinary interface variable type"]

Diagram: every interface defines a type set, assembled from method elements, bare type terms, ~T elements, unions, and comparable. The insight: a constraint is nothing but an interface read as “the permitted set of type arguments” — the same syntax serves both roles, but type-set elements beyond methods restrict the interface to constraint-only use.

Mechanical Walk-through

The four kinds of interface element

The Interface types section of the spec defines the type set as the intersection of the type sets of each interface element. An element is one of:

  1. A method specification. String() string denotes the set of all non-interface types whose method set includes that method.
  2. A bare type term T. A type name denotes the set containing just that one type.
  3. An underlying-type element ~T. This denotes the set of all types whose underlying type is T. ~int includes int itself plus type Celsius int, type Age int, and every other named type defined as int.
  4. A union t1 | t2 | ... | tn. The union of the type sets of its terms.

An interface body lists several elements separated by ; or newlines; the resulting type set is their intersection. So interface{ ~int; String() string } is “types with underlying type int and a String method.” An empty interface (interface{}, i.e. any) is the set of all non-interface types. By construction, an interface’s type set never contains an interface type — it is always a set of concrete types.

The ~ underlying-type element

The ~ token (new in Go 1.18) is the bridge between named types and underlying types. Recall (see Type Identity and Assignability) that every type has an underlying type: for type Celsius float64, the underlying type is float64. A bare term float64 in a constraint matches only the predeclared float64 — a user’s Celsius would fail the constraint. The ~float64 form matches float64 and every named type built on it. Generic numeric code almost always wants ~, because callers routinely pass named types (time.Duration is ~int64, for instance).

The spec imposes restrictions on ~T: the T in ~T must be a type whose underlying type is itself (you cannot write ~Celsius, only ~float64), and T may not be an interface. ~error is illegal because error is an interface.

Union elements and their restrictions

A union A | B | C denotes the union of the three type sets. interface{ ~int | ~int64 } is the set of all int-underlying and int64-underlying types. The Type constraints section imposes three rules:

  • A term T or ~T in a union cannot be a type parameter. [P any, Q int | P] is illegal — P cannot appear in a union.
  • The type sets of all non-interface terms must be pairwise disjoint — they may not overlap. ~int | MyInt (where MyInt is type MyInt int) is illegal because MyInt’s set is inside ~int’s set.
  • As an implementation restriction, a union with more than one term cannot contain comparable or an interface that has methods. You may not write int | fmt.Stringer. Unions are for assembling sets of concrete types, not for mixing in behavioral interfaces.

Constraint-only interfaces

Because ~T and | elements restrict by type identity rather than behavior, an interface containing them — and an interface embedding comparable — may be used only as a constraint, never as the type of a variable, parameter, or struct field. interface{ ~int } cannot type a variable; it can only bound a type parameter. A basic interface — methods only, no ~, no |, no comparable — can be used both ways, exactly as interfaces always could. This is the dividing line between the two roles of the unified interface concept.

The Type parameter declarations section also defines a convenience: when a constraint is a single embedded type element, the enclosing interface{ } may be dropped. [T ~int] is shorthand for [T interface{ ~int }]; [T int | string] for [T interface{ int | string }].

comparable and strict comparability

comparable is a predeclared interface (Go 1.18) denoting “the set of all strictly comparable types” — types the compiler can guarantee will never panic under == or !=. Integers, floats, strings, pointers, channels, and structs/arrays composed entirely of strictly-comparable fields are strictly comparable. Slices, maps, and functions are not comparable at all. Interface types are comparable but not strictly comparable — comparing two interfaces can panic at runtime if the dynamic types are non-comparable (e.g. both hold a slice). So a plain interface type does not implement comparable.

Go 1.20 introduced a subtle but important refinement, explained in All your comparable types: the distinction between implementing a constraint and satisfying it. The Satisfying a type constraint section states a type argument T satisfies constraint C if T implements C, or C can be written as interface{ comparable; E } with E a basic interface and T is comparable (not necessarily strictly) and implements E. The practical effect: before Go 1.20, comparable-constrained generic code rejected any as a type argument because any is not strictly comparable; from Go 1.20 on, any satisfies comparable, so map[K]V generic over comparable K can be instantiated with K = any — matching how the built-in map already behaves. The trade-off: a == on such a K can panic at runtime, just as map[any]V already could.

Code Examples

Building a numeric constraint with ~ and |

// Integer is the union of all signed and unsigned integer underlying types.
type Integer interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
 
type Celsius int
 
func SumAll[T Integer](xs []T) T {
	var total T
	for _, x := range xs {
		total += x // legal: every type in Integer's set supports +
	}
	return total
}
 
func use() {
	temps := []Celsius{10, 20, 30}
	_ = SumAll(temps) // OK: Celsius's underlying type int is in the ~int set
}

Integer is a union of ~-prefixed terms. The ~ is what makes the call SumAll(temps) legal: Celsius is a named type, and a bare int term would not match it — ~int does, because Celsius’s underlying type is int. Inside SumAll, total += x type-checks because the compiler can prove every member of Integer’s type set supports +.

The standard cmp.Ordered constraint

// From the standard library cmp package (added in Go 1.21):
type Ordered interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
		~float32 | ~float64 |
		~string
}

cmp.Ordered is the canonical real constraint: the union of every type for which <, <=, >, >= are defined — all numeric kinds plus string — each with ~ so named types qualify. Note it excludes complex64/complex128: complex numbers are not ordered in Go. The golang.org/x/exp/constraints package’s Ordered is now a type alias for cmp.Ordered; its Signed, Unsigned, Integer, Float, and Complex constraints are still useful and are plain unions of ~-terms.

Mixing a type set with a method requirement

type Stringy interface {
	~string                // restricts the underlying type
	IsValid() bool         // AND requires a method
}
 
func PrintValid[T Stringy](xs []T) {
	for _, x := range xs {
		if x.IsValid() {        // legal: the constraint guarantees IsValid
			s := string(x)      // legal: ~string guarantees the conversion
			println(s)
		}
	}
}

Stringy’s type set is the intersection of “underlying type string” and “has method IsValid() bool.” Inside the body, both facts are usable: x.IsValid() because of the method element, and string(x) because the ~string element proves the conversion is valid for every permitted T.

comparable as a constraint

func Index[T comparable](xs []T, target T) int {
	for i, x := range xs {
		if x == target { // legal: comparable guarantees == is defined
			return i
		}
	}
	return -1
}
 
func use() {
	_ = Index([]int{1, 2, 3}, 2)            // OK: int is strictly comparable
	_ = Index([]any{1, "x", true}, "x")     // OK since Go 1.20: any *satisfies* comparable
	// _ = Index([][]int{{1}}, []int{1})    // COMPILE ERROR: []int is not comparable at all
}

comparable makes x == target type-check. The middle call relies on the Go 1.20 satisfies-vs-implements rule: any does not implement comparable (it is not strictly comparable) but it satisfies the constraint, so the instantiation Index[any] is allowed — at the cost that == on the boxed values could panic if they hold non-comparable dynamic types. The last (commented) call fails outright: []int is not comparable under any rule.

Failure Modes and Common Misunderstandings

Forgetting ~ and rejecting named types. A constraint interface{ int | string } accepts only the predeclared int and string. A caller passing time.Duration (a named int64) or any type MyString string gets a confusing “does not satisfy constraint” error. Numeric/string constraints almost always want ~.

Using a constraint-only interface as a variable type. var x interface{ ~int } does not compile — interfaces with ~, |, or embedded comparable are constraints only. The error (“cannot use … outside a type constraint”) surprises people who think of constraints as ordinary interfaces.

Overlapping union terms. ~int | int32 or ~int | MyInt is rejected because the non-interface terms’ type sets must be pairwise disjoint. The fix is to drop the redundant narrower term.

Mixing methods into a union. int | fmt.Stringer is illegal — a multi-term union cannot contain a method-bearing interface. Methods and unions combine via intersection (separate elements on separate lines), not within a union.

Assuming interface types implement comparable. Pre-Go-1.20 mental models say “any is comparable, so it implements comparable.” It does not implement it (interfaces are not strictly comparable); since Go 1.20 it satisfies it. The distinction matters when you try to make a generic type’s comparable parameter usable as an ordinary interface value — it still cannot be.

Expecting field access from a type set. A constraint like interface{ ~struct{ X int } } does not let you read t.X inside the generic body. Type sets constrain operations and identity, not field access — see Generics and Type Parameters.

Alternatives and When to Choose Them

  • Method-only (basic) interfaces. When the constraint is genuinely “any type with these methods,” a plain interface is the constraint — and it doubles as an ordinary interface type. No ~/| needed.
  • any as the constraint. When the generic code does not need any operation on T beyond moving it around (a generic container that only stores and returns values), constrain with any. Adding a narrower constraint you do not use is noise.
  • comparable. Exactly when the code does ==/!= on T (map keys, set membership, equality search) and nothing else.
  • cmp.Ordered / constraints.*. When the code needs </ordering or arithmetic, reach for the standard cmp.Ordered rather than hand-rolling a union — it is exhaustive and correct, and a shared constraint reads better than a bespoke one.
  • Custom unions. Only when none of the standard constraints fits — e.g. “integers only, no floats.” Keep them in one place and reuse.

Production Notes

The standard library’s cmp package (Go 1.21) is the reference for constraint design — cmp.Ordered is the union you should reuse rather than re-derive. The slices and maps packages (Go 1.21) lean heavily on comparable (for slices.Contains, slices.Index, maps.Equal) and cmp.Ordered (for slices.Sort, slices.Max, slices.Min). The golang.org/x/exp/constraints package predates cmp and is now partly redundant — its Ordered is a mere alias for cmp.Ordered — but constraints.Integer, constraints.Float, and constraints.Signed/Unsigned remain useful when you need finer-grained numeric categories than cmp.Ordered offers.

The Go 1.20 comparable-satisfaction change is a subtle real-world gotcha that bit early generic libraries: a generic Set[T comparable] written in Go 1.18/1.19 could not be instantiated as Set[any], which broke “set of arbitrary values” use cases; from Go 1.20 it can — but the library now silently inherits the runtime-panic risk of comparing interface values holding non-comparable types, exactly as map[any]struct{} always has. Library authors targeting comparable should document this and, where strict comparability is genuinely required, use the func isComparable[_ comparable]() assertion trick (from the comparable blog post) to force a compile-time check.

As of the Go 1.26 baseline the type-set model is unchanged since 1.18, with the 1.20 satisfaction refinement being the one substantive evolution. The bigger post-1.18 movement has been in type inference (Go 1.21), not in constraints themselves.

See Also