Skip to content

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 Total does. It is the only way to require both, and the compiler checks both against every type in the set.
  • Expect the panic when comparable meets an interface. A Set[any] or a map[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 go line 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.

Registry[any, string]: Get(404) = "not found", true; len = 2
Put([]byte key) panicked: runtime error: hash of unhashable type []uint8
Describe over ~string with Kind:
  label: "paid" (4 bytes)
  label: "overdue" (7 bytes)

In other languages

  • Rust — a bound is a trait, and there is no union: to say "i32 or f64" 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 what comparable most resembles. Rust has no run-time counterpart to the Go 1.20 rule: a HashMap key needs Eq + Hash at compile time, and the comparison traits ↗ explain why f64 is PartialEq and not Eq.
  • 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 ↗); and equals on a map key of any type compiles and never panics — the Go 1.20 rule is Java's ordinary day.

Sources