Go Constraints: comparable, Ordered and Custom Sets
Reviewer’s note: This tutorial explains what a Go constraint actually is: an interface used as a type set, not just a method set. You will learn the built-in
anyandcomparable, type unions, the~underlying-type token,cmp.Ordered, and how to write your ownNumberconstraint. Every example compiles on Go 1.24.7 with real output.
Works with Go 1.21+ (the cmp package needs 1.21; everything else works on 1.18+). This is part of the Go generics guide; if type parameters are new to you, read type parameters first. If Go itself is new, start with the complete Go tutorial.
A constraint is a type set, not a method set
When you write a generic function, the type parameter needs a bound. That bound is a constraint, and a constraint is just an interface. The twist is what the interface means in this position.
In ordinary code an interface describes a method set: “any type with these methods”. As a constraint it describes a type set: “any type in this set of types”. The Go spec unifies both ideas: every interface defines a set of types, and the methods it lists are simply one way of describing that set (all types that have those methods).
Why this matters shows up the moment you try to do arithmetic. Here is a Max bounded by any:

package main
import "fmt"
// Max is constrained to any, so the body cannot use > on values of type T.
func Max[T any](a, b T) T {
if a > b { // this line does not compile
return a
}
return b
}
func main() {
fmt.Println(Max(3, 7))
}
The compiler stops you:
./main.go:7:5: invalid operation: a > b (type parameter T cannot use operator >)
any is the widest type set (every type), so the only operations the compiler can guarantee are the ones every type supports: assignment, passing around, == against nil. It cannot promise >, because T might be a struct or a map. The constraint controls exactly which operations the body may use. That is the whole job of a constraint: widen the set of accepted types as far as you can while still permitting the operations you need.
The two built-in constraints: any and comparable
Go ships two constraints you never have to define.
any is an alias for interface{}, added in Go 1.18. It is the empty type set bound: it accepts everything and permits almost nothing, as you just saw.
comparable is the constraint for types whose values support == and !=. That, and only that. It is what you need for map keys, set membership, and deduplication:
package main
import "fmt"
// indexOf works for any type whose values can be compared with ==.
// The comparable constraint permits == and !=, nothing else.
func indexOf[T comparable](items []T, target T) int {
for i, item := range items {
if item == target {
return i
}
}
return -1
}
func main() {
ids := []string{"ord-1", "ord-2", "ord-3"}
fmt.Println(indexOf(ids, "ord-2"))
ports := []int{8080, 8443, 9090}
fmt.Println(indexOf(ports, 9090))
fmt.Println(indexOf(ports, 22))
}
Output:
1
2
-1
The trap people walk into: comparable gives you equality, never ordering. Try < under it and you get the same refusal as under any:
func maxComparable[T comparable](a, b T) T {
if a < b { // does not compile
return b
}
return a
}
./main.go:7:5: invalid operation: a < b (type parameter T cannot use operator <)
Equality is defined for structs, arrays, pointers, channels, and interfaces. Ordering is not. comparable covers the first group; for ordering you need a different constraint, which is the section after next.
The subtlety: comparable and the Go 1.20 change
comparable means == is defined, not that it always succeeds. Before Go 1.20 the constraint accepted only strictly comparable types, ones where == can never panic. That excluded interface types, because comparing two interfaces whose dynamic type is a slice panics at runtime.
Go 1.20 loosened it. From the release notes: comparable types “such as ordinary interfaces may now satisfy comparable constraints, even if the type arguments are not strictly comparable (comparison may panic at runtime)”. So this generic set, keyed by any, compiles on Go 1.20 and later where it would not before:
package main
import "fmt"
// A generic set backed by a map. The key type must be comparable
// because map keys must support ==.
type Set[T comparable] struct {
items map[T]struct{}
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{items: make(map[T]struct{})}
}
func (s *Set[T]) Add(item T) { s.items[item] = struct{}{} }
func (s *Set[T]) Contains(item T) bool { _, ok := s.items[item]; return ok }
func (s *Set[T]) Len() int { return len(s.items) }
func main() {
// Since Go 1.20, interface types satisfy comparable even though
// comparing them can panic. So any is a legal key type here.
seen := NewSet[any]()
seen.Add("ord-1")
seen.Add(42)
seen.Add("ord-1")
fmt.Println("len:", seen.Len())
fmt.Println("has 42:", seen.Contains(42))
}
Output:
len: 2
has 42: false
The cost of that flexibility is real. Feed the set a value whose dynamic type is not strictly comparable and it panics where the map hashes the key:
panic: runtime error: hash of unhashable type []string
So comparable now buys you interface keys at the price of a possible runtime panic. Useful, but know the tradeoff before you key a map on any.
Type unions: listing the types you accept
The most direct way to write a constraint is to list the concrete types in a union with |. The interface below is a constraint whose type set is exactly three types:
// The type set is the union of three concrete types. Inside the body
// you may use every operation valid for ALL of them: arithmetic and
// ordering are defined for int, int64 and float64, so + and > are allowed.
type numeric interface {
int | int64 | float64
}
func clampToMax[T numeric](value, limit T) T {
if value > limit {
return limit
}
return value
}
Running clampToMax(120, 100), clampToMax(3.5, 10.0), and clampToMax[int64](42, 100) prints:
100
3.5
42
The rule for what operations a union permits: an operation is allowed only if it is valid for every type in the set. All three of these support +, -, *, /, and the ordering operators, so the body can use any of them. Add string to the union and - would stop compiling, because subtraction is undefined for strings. More on that under common mistakes.
The ~ token: accepting named types by underlying type

Here is the mistake that catches everyone the first week with generics. A union of float64 accepts float64, but it does not accept a type you defined on top of float64:
type exactFloat interface {
float64
}
func half[T exactFloat](v T) T { return v / 2 }
type Celsius float64 // underlying type float64, but NOT float64
func main() {
var body Celsius = 37.0
fmt.Println(half(body))
}
./main.go:19:18: Celsius does not satisfy exactFloat (possibly missing ~ for float64 in exactFloat)
Celsius has underlying type float64, but it is a distinct named type, so it is not in the set {float64}. The compiler even tells you the fix. The ~ token means “any type whose underlying type is this”. Change one character and named types are welcome:
// ~float64 means "any type whose underlying type is float64".
type floatlike interface {
~float64
}
func half[T floatlike](v T) T { return v / 2 }
type Celsius float64
func main() {
var body Celsius = 37.0
fmt.Printf("%.1f\n", half(body))
fmt.Printf("%.1f\n", half(9.8)) // plain float64 still works
}
Output:
18.5
4.9
The rule of thumb: unless you have a specific reason to reject named types, put ~ in front of every element of a numeric or string union. Domain code is full of types like Celsius, UserID, and Money defined on numeric bases, and a constraint without ~ silently locks all of them out.
Combining a method requirement with a type set
A constraint interface can list both a type set and a method. The satisfying type must be in the type set and have the method. This is where the “interface as constraint” model pays off: you get compile-time arithmetic from the type set and a behavior guarantee from the method, in one bound.
// T must have an underlying integer type AND a Label() string method.
type LabeledID interface {
~int64
Label() string
}
type UserID int64
func (u UserID) Label() string { return fmt.Sprintf("user-%d", int64(u)) }
type OrderID int64
func (o OrderID) Label() string { return fmt.Sprintf("order-%d", int64(o)) }
// describe uses the type-set half (arithmetic on the underlying int64)
// and the method half (Label) in the same body.
func describe[T LabeledID](id T) string {
next := id + 1 // permitted: underlying type is int64
return fmt.Sprintf("%s (next raw id: %d)", id.Label(), int64(next))
}
func main() {
fmt.Println(describe(UserID(42)))
fmt.Println(describe(OrderID(1007)))
}
Output:
user-42 (next raw id: 43)
order-1007 (next raw id: 1008)
Inside describe, id + 1 works because the type set is ~int64, and id.Label() works because the constraint also requires that method. Neither half alone would compile. This is the constraint form you reach for when a generic algorithm needs both math and behavior, and it has no equivalent in a plain method-set interface. For a refresher on how methods attach to types, see structs and methods and the interfaces guide.
cmp.Ordered: the constraint you should not hand-write
For years the ordered constraint lived in golang.org/x/exp/constraints as constraints.Ordered, an experimental package you imported yourself. Go 1.21 promoted it to the standard library in the cmp package as cmp.Ordered. Its definition is exactly the union you would write, with ~ on every element:
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}
Because it is in the standard library and correct, you should use it instead of writing your own. Here is Max over a slice, done right, with error handling for the empty case:
package main
import (
"cmp"
"fmt"
)
// cmp.Ordered (Go 1.21+) is the canonical constraint for any type
// that supports < <= >= >. You almost never hand-write this.
func Max[T cmp.Ordered](values []T) (T, error) {
if len(values) == 0 {
var zero T
return zero, fmt.Errorf("Max: empty slice")
}
best := values[0]
for _, v := range values[1:] {
if v > best {
best = v
}
}
return best, nil
}
func main() {
latency, err := Max([]int{42, 17, 88, 5})
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("max latency:", latency)
name, _ := Max([]string{"gamma", "alpha", "delta"})
fmt.Println("last name:", name)
_, err = Max([]float64{})
fmt.Println("empty:", err)
}
Output:
max latency: 88
last name: gamma
empty: Max: empty slice
cmp also gives you cmp.Compare and cmp.Less as functions, which pair well with the slices package (slices.SortFunc, slices.MaxFunc). Reach for the standard library first; a hand-written ordered constraint is a maintenance liability, as the common mistakes show.
A custom Number constraint and constraint type inference
There is no Number in the standard library (numbers split across signed, unsigned, and float, and cmp.Ordered deliberately includes string). When your domain is genuinely numeric, define one. Every element gets ~ so named types pass:
// Number is a domain constraint: every integer and float kind,
// each with ~ so named types are accepted.
type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
func Sum[T Number](values []T) T {
var total T
for _, v := range values {
total += v
}
return total
}
Sum([]int{10, 20, 30}) prints 60 and Sum([]float64{1.5, 2.5, 4.0}) prints 8. Notice the call site: you never wrote Sum[int](...). The compiler read T from the argument. That is function argument type inference, and it is why generic code stays readable.
Go has a second, less obvious form: constraint type inference, which infers one type parameter from another using the constraints themselves. It fires when a constraint has a core type like ~[]E:
// Scale takes a named slice type S whose underlying type is []E.
// E is found by CONSTRAINT type inference from S's ~[]E constraint,
// so Scale returns the SAME named slice type it was given.
func Scale[S ~[]E, E Number](values S, factor E) S {
out := make(S, len(values))
for i, v := range values {
out[i] = v * factor
}
return out
}
type Prices []float64
func main() {
list := Prices{9.99, 19.99, 4.50}
doubled := Scale(list, 2) // no type arguments written
fmt.Printf("%T %v\n", doubled, doubled)
}
Output:
main.Prices [19.98 39.98 9]
The compiler inferred S as Prices from the argument, then inferred E as float64 from S’s ~[]E constraint, so the return type stayed Prices rather than collapsing to []float64. Preserving the named type is the reason to write ~[]E instead of taking a plain []E. For more on slice types, see arrays, slices, and maps.
Layer 3: a stats helper constrained to your Number set
Here is the pattern in a shape close to real code: a tiny stats package that computes the mean and peak of any numeric slice, including domain types like Celsius and LatencyMS. Mean returns float64 so integer inputs do not truncate; Peak keeps the caller’s type via cmp.Ordered.
package main
import (
"cmp"
"fmt"
)
type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
// Mean returns the average as float64 so integer inputs do not truncate.
func Mean[T Number](values []T) (float64, error) {
if len(values) == 0 {
return 0, fmt.Errorf("Mean: no values")
}
var total float64
for _, v := range values {
total += float64(v)
}
return total / float64(len(values)), nil
}
// Peak keeps the original type T, so callers get a Celsius back, not a float64.
func Peak[T cmp.Ordered](values []T) (T, error) {
if len(values) == 0 {
var zero T
return zero, fmt.Errorf("Peak: no values")
}
best := values[0]
for _, v := range values[1:] {
if v > best {
best = v
}
}
return best, nil
}
type Celsius float64
type LatencyMS int64
func main() {
temps := []Celsius{19.5, 22.0, 27.5, 21.0}
avg, err := Mean(temps)
if err != nil {
fmt.Println("error:", err)
return
}
peak, _ := Peak(temps)
fmt.Printf("temps: mean=%.2f peak=%.1f (%T)\n", avg, peak, peak)
latencies := []LatencyMS{42, 17, 88, 5, 63}
lAvg, _ := Mean(latencies)
lPeak, _ := Peak(latencies)
fmt.Printf("latency: mean=%.1fms peak=%dms (%T)\n", lAvg, lPeak, lPeak)
}
Output:
temps: mean=22.50 peak=27.5 (main.Celsius)
latency: mean=43.0ms peak=88ms (main.LatencyMS)
Peak returned a Celsius and a LatencyMS, not bare floats and ints, because T is preserved through the constraint. The ~ tokens in Number are what let the named types through in the first place. That combination, a domain constraint with ~ plus type preservation, is most of what you want from numeric generics in a real service. Wrapping errors around these helpers follows the usual error handling patterns.
Common mistakes with Go constraints
Forgetting the ~ token
You already saw the compile error. It resurfaces the instant someone passes a named type to a constraint built from plain type names:
type MyOrdered interface {
int | int64 | float64 | string // no ~, so named types are rejected
}
func Min[T MyOrdered](a, b T) T {
if a < b {
return a
}
return b
}
type Celsius float64
// Min(Celsius(19.5), Celsius(22.0)) fails to compile
./main.go:22:17: Celsius does not satisfy MyOrdered (possibly missing ~ for float64 in MyOrdered)
Default to ~ on numeric and string unions. Omit it only when you deliberately want to reject named types.
Using comparable when you need ordered
comparable gives ==, not <. Reaching for it to write a Min or a sort produces type parameter T cannot use operator <. If your algorithm orders values, the constraint is cmp.Ordered, not comparable. If it only checks membership or builds a set, comparable is correct.
Hand-writing an Ordered constraint instead of using cmp.Ordered
The MyOrdered above is not just verbose, it is a bug surface. It forgot ~, and it also omitted uintptr, which the real cmp.Ordered includes. Every hand-rolled ordered constraint is a chance to miss a type or a tilde. Since Go 1.21 the correct, complete definition ships in the standard library. Import cmp and use cmp.Ordered. Write your own union only for sets the standard library does not cover, like a numbers-only Number.
Over-broad constraints that permit invalid operations
A constraint that is too wide compiles at the definition but fails at the operation. Put ~string in a union alongside numbers and any subtraction breaks:
type Addable interface {
~int | ~int64 | ~float64 | ~string
}
func diff[T Addable](a, b T) T {
return a - b // - is not defined for string
}
./main.go:13:9: invalid operation: operator - not defined on a (variable of type T constrained by Addable)
The compiler allows only operations valid for every type in the set. A union that mixes strings and numbers permits + (both concatenate or add) but nothing that strings lack. Size the constraint to the operations the body actually performs: no wider, no narrower.
What next
Constraints are one piece of Go generics. From here:
- Zoom out to the Go generics guide, the pillar that ties constraints, type parameters, and generic types together.
- Get precise about the syntax in type parameters, including instantiation and inference rules.
- Put constraints to work in generic functions and in generic data structures like stacks, trees, and typed maps.
- Constraints build on interfaces; if the “interface as type set” idea felt new, revisit Go interfaces.
- Constraint tradeoffs, the typed-nil trap, and the Go 1.20
comparablechange are frequent interview material. Test yourself against 50+ Go interview questions.
If any of the underlying Go felt shaky, the complete Go tutorial covers types, methods, and slices from the ground up.