Constraints are type sets¶
Level: 301 · anyone who tried to declare a variable of a constraint type
One line: A constraint is an interface read as a set of types — a union ~int | ~string lists the set, a method written beside the union narrows it to the types that also have the method — and an interface that contains a union is usable only as a constraint, never as the type of a value; since Go 1.20 comparable is satisfied by interface types as well, at the price of an == that can panic at run time.
Key is a pure union. Quantity is a union and a method, and Grams and Litres are in its set because they are built on int and float64 and have Unit. Index and Set are constrained by comparable and instantiated with any, and the last four lines compare values whose dynamic types the compiler never saw.
Verified output of constraints_are_type_sets_go.go — regenerated by tools/run_examples.py, never hand-typed.
Join over ~int | ~string: 7;12; pear;apple;
Total over a union with a method: 375 g | 1.75 L
Index([]any{1, "a", 2}, 2) = 2
Index([]any{[]int{1}, 2}, 2) = 1
Index([]any{[]int{1}}, any([]int{1})) = panic: runtime error: comparing uncomparable type []int
Set[any] with a []int key: panic: runtime error: hash of unhashable type []int
Reading the output¶
A union is a set of types. The spec ↗ defines the type set of ~int | ~string as the union of its terms' sets: every type built on int and every type built on string. Join[T Key] may do with a T only what both halves allow — here fmt.Sprint, which takes anything — and []int{7, 12} and []string{"pear", "apple"} both go through.
A method beside the union narrows it. An interface's type set is the intersection of its elements' sets, and a method element's set is every type that has the method. Quantity's set is therefore the types built on int or float64 that also have Unit() string: Grams and Litres are in, and a plain int is out — the first driver below records the refusal. Inside Total, total += v is allowed by the union and total.Unit() by the method; neither element alone would let the function be written.
comparable admits any — and the comparison decides at run time. Index([]any{1, "a", 2}, 2) finds 2 at index 2. Before Go 1.20 that call did not compile; the 1.20 release notes ↗ let interface types satisfy comparable even though comparing them may panic, and the spec's constraint satisfaction rule ↗ now carries that exception. Index([]any{[]int{1}, 2}, 2) returns 1 and did not panic on the way past []int{1}: two interface values are compared by dynamic type first, and only if the types are identical are the values compared (spec ↗) — []int against int is simply unequal. Index([]any{[]int{1}}, any([]int{1})) compares two []int values and panics with comparing uncomparable type []int; a []int key in a Set[any] panics with hash of unhashable type []int. Both are runtime errors from runtime/alg.go ↗, and both depend on the data, not the code: the same Index is safe or not according to what a caller puts into the []any.
A constraint is not a type¶
Verified output of constraints_outside_a_constraint_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go build outside.go
# command-line-arguments
./outside.go:23:17: cannot use type Quantity outside a type constraint: interface contains type constraints
./outside.go:26:16: cannot use type Quantity outside a type constraint: interface contains type constraints
./outside.go:28:19: in call to Total, T (type int) does not satisfy Quantity (missing method Unit)
exit status 1
The spec ↗ divides interfaces into basic ones, with methods only, and the rest. A basic interface is a type, the familiar kind: a value of it holds anything with the methods. An interface with a union is not — it may only constrain a type parameter or be embedded in another constraint, so Describe(q Quantity) and []Quantity are both refused with cannot use type Quantity outside a type constraint: interface contains type constraints. There is no value that is "an int or a float64 with a Unit method"; there are only functions written once for every such type, Total[T Quantity]. The third line is the narrowing at work: int is in the union and lacks the method.
The go line decides which rule applies¶
Verified output of constraints_comparable_before_go120_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ cat go.mod
module example
go 1.19
$ go build .
# example
./main.go:15:19: any to satisfy comparable requires go1.20 or later (-lang was set to go1.19; check go.mod)
exit status 1
$ go mod edit -go=1.20 && go build . && ./example
2
exit status 0
The same Index([]any{…}, 2), in a module whose go.mod says go 1.19, is refused by a Go 1.25 compiler: any to satisfy comparable requires go1.20 or later (-lang was set to go1.19; check go.mod). The go directive ↗ sets the language version the compiler enforces, not which toolchain runs, so a module that has never raised its line is still held to the older constraint-satisfaction rule. go mod edit -go=1.20 and the program builds and prints 2.
constraints_are_type_sets_go.go, constraints_outside_a_constraint_sh.sh and constraints_comparable_before_go120_sh.sh
constraints_are_type_sets_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// A constraint is an interface read as a set of types. A union lists the set;
// a method narrows it to the types that have the method; and since Go 1.20
// comparable admits interface types too -- whose == can panic at run time,
// depending on the dynamic types being compared.
//
// go build constraints_are_type_sets_go.go && ./constraints_are_type_sets_go
package main
import "fmt"
// Key is a pure type set: every type built on int or on string.
type Key interface {
~int | ~string
}
func Join[T Key](keys []T) string {
out := ""
for _, k := range keys {
out += fmt.Sprint(k) + ";"
}
return out
}
// Quantity is a type set AND a method: the numeric types that also have Unit.
type Quantity interface {
~int | ~float64
Unit() string
}
type Grams int
func (Grams) Unit() string { return "g" }
type Litres float64
func (Litres) Unit() string { return "L" }
// Total uses + (from the union) and Unit (from the method).
func Total[T Quantity](values []T) string {
var total T
for _, v := range values {
total += v
}
return fmt.Sprintf("%v %s", total, total.Unit())
}
func Index[T comparable](values []T, want T) int {
for i, v := range values {
if v == want {
return i
}
}
return -1
}
type Set[T comparable] map[T]struct{}
// outcome runs f and reports what it returned or what it panicked with.
func outcome(f func() any) string {
var result any
func() {
defer func() {
if r := recover(); r != nil {
result = fmt.Sprint("panic: ", r)
}
}()
result = f()
}()
return fmt.Sprint(result)
}
func main() {
fmt.Println("Join over ~int | ~string:", Join([]int{7, 12}), Join([]string{"pear", "apple"}))
fmt.Println("Total over a union with a method:", Total([]Grams{250, 125}), "|", Total([]Litres{1.5, 0.25}))
// comparable, instantiated with the interface type any (Go 1.20+).
fmt.Println("Index([]any{1, \"a\", 2}, 2) =", outcome(func() any { return Index([]any{1, "a", 2}, 2) }))
fmt.Println("Index([]any{[]int{1}, 2}, 2) =", outcome(func() any { return Index([]any{[]int{1}, 2}, 2) }))
fmt.Println("Index([]any{[]int{1}}, any([]int{1})) =", outcome(func() any { return Index([]any{[]int{1}}, any([]int{1})) }))
fmt.Println("Set[any] with a []int key:", outcome(func() any {
seen := Set[any]{}
seen[[]int{1}] = struct{}{}
return len(seen)
}))
}
constraints_outside_a_constraint_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# An interface with a union is a type set, not a type: it may constrain a type
# parameter and nothing else. And a type that is in the union but lacks the
# method is outside the set.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cat >"$dir/outside.go" <<'GO'
package main
import "fmt"
type Quantity interface {
~int | ~float64
Unit() string
}
type Grams int
func (Grams) Unit() string { return "g" }
func Total[T Quantity](values []T) string {
var total T
for _, v := range values {
total += v
}
return fmt.Sprintf("%v %s", total, total.Unit())
}
// Describe wants "any quantity" as an ordinary interface value.
func Describe(q Quantity) string { return q.Unit() }
func main() {
var pending []Quantity
fmt.Println(Total([]Grams{250}), pending)
fmt.Println(Total([]int{250}))
}
GO
say() { printf '$ %s\n' "$*"; }
say 'go build outside.go'
(cd "$dir" && go build outside.go) 2>&1
echo "exit status $?"
constraints_comparable_before_go120_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# The same code, in a module whose go line says 1.19: the compiler applies the
# older rule, under which an interface type does not satisfy comparable. The
# go line changes what compiles, not which toolchain runs.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
cat >go.mod <<'MOD'
module example
go 1.19
MOD
cat >main.go <<'GO'
package main
import "fmt"
func Index[T comparable](values []T, want T) int {
for i, v := range values {
if v == want {
return i
}
}
return -1
}
func main() {
fmt.Println(Index([]any{1, "a", 2}, 2))
}
GO
say() { printf '$ %s\n' "$*"; }
say 'cat go.mod'
cat go.mod
say 'go build .'
go build . 2>&1
echo "exit status $?"
say 'go mod edit -go=1.20 && go build . && ./example'
go mod edit -go=1.20 && go build . && ./example
echo "exit status $?"
What to do¶
- A union is for
[T …]and nothing else. To hold "one of these types" in a variable, use a basic interface (methods only) and a type switch, or a struct with a kind field; a constraint cannot be that variable's type. - Combine a union and a method when the body needs an operator and a method call, as
Totaldoes. It is the only way to require both, and the compiler checks both against every type in the set. - Expect the panic when
comparablemeets an interface. ASet[any]or amap[any]…is exactly as safe as its keys: any caller can put a slice into it. Constrain by a concrete comparable type where you can, or document that the key must be strictly comparable. - Keep the module's
goline at the version whose rules the code relies on. A constraint that compiles in one module and not in another is usually this line.
Practice¶
Define Registry[K comparable, V any] on a map with Put and Get, instantiate it as Registry[any, string], store one entry under an int key and one under a string key, and print the lookup of the int. Then, inside a function with a deferred recover, Put a []byte key and print the recovered panic. Last, define Tag interface{ ~string; Kind() string }, a Label string type with Kind, and Describe[T Tag], which prints each tag's kind and text — the method from the interface, the text from the ~string term.
Solution
constraints_are_type_sets_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// Kata: a Registry keyed by any -- comparable admits the interface type, and
// a []byte key panics at run time -- and a Tag constraint that is both a
// ~string term and a method.
//
// go build constraints_are_type_sets_kata_go.go && ./constraints_are_type_sets_kata_go
package main
import "fmt"
type Registry[K comparable, V any] map[K]V
func (r Registry[K, V]) Put(k K, v V) { r[k] = v }
func (r Registry[K, V]) Get(k K) (V, bool) {
v, ok := r[k]
return v, ok
}
// Tag: the string types that also say what kind of tag they are.
type Tag interface {
~string
Kind() string
}
type Label string
func (Label) Kind() string { return "label" }
func Describe[T Tag](tags []T) {
for _, t := range tags {
fmt.Printf(" %s: %q (%d bytes)\n", t.Kind(), string(t), len(t))
}
}
func main() {
handlers := Registry[any, string]{}
handlers.Put(404, "not found")
handlers.Put("timeout", "retry")
v, ok := handlers.Get(404)
fmt.Printf("Registry[any, string]: Get(404) = %q, %t; len = %d\n", v, ok, len(handlers))
func() {
defer func() { fmt.Println("Put([]byte key) panicked:", recover()) }()
handlers.Put([]byte("raw"), "never stored")
}()
fmt.Println("Describe over ~string with Kind:")
Describe([]Label{"paid", "overdue"})
}
Verified output of constraints_are_type_sets_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.
In other languages¶
- Rust — a bound is a trait, and there is no union: to say "
i32orf64" you write a trait and implement it for each (What a trait is ↗); a bound with no methods at all is a marker trait ↗, which is whatcomparablemost resembles. Rust has no run-time counterpart to the Go 1.20 rule: aHashMapkey needsEq + Hashat compile time, and the comparison traits ↗ explain whyf64isPartialEqand notEq. - C++ — a C++20 concept is a predicate on a type, and a union is written as one:
std::same_as<T, int> || std::same_as<T, double>(cppreference ↗). Like Go's, a concept is not a type a variable can have. - Java — a type variable can be bounded,
<T extends Comparable<T>>, and intersected with&, but never unioned (the Java tutorial ↗); andequalson a map key of any type compiles and never panics — the Go 1.20 rule is Java's ordinary day.
Sources¶
- The Go specification: General interfaces ↗ (unions,
~, and intersection of elements), Basic interfaces ↗, Type constraints ↗, Satisfying a type constraint ↗ (thecomparableexception) and Comparison operators ↗ (interface values, and the run-time panic). - Go 1.20 release notes, Changes to the language ↗ — interfaces satisfying
comparable; Go 1.18 release notes, Generics ↗ — the restriction that a union of more than one term may not contain an interface with methods. - Robert Griesemer, All your comparable types ↗, the Go blog, 17 February 2023 — strictly comparable versus comparable, and why the rule changed.
src/runtime/alg.goat go1.25.5 ↗ — the two panic messages.- Go Modules Reference, the
godirective ↗. - Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 8, "Generics" — the sections "Generics and Interfaces" and "More on comparable".
- Go in Practice, 2nd ed. (Manning, 2025), chapter 3 — "Simplifying code with generics", its part "Using constraints and type approximations".