A Constraint Is a Set of Types, Not a List of Methods
A shared helper processes identifiers — type Slug string — alongside plain string values. The constraint is written the obvious way:
func Clean[T string](s T) T { // rejects Slug at the call site
return T(strings.ToLower(string(s)))
}
type Slug string
_ = Clean(Slug("Hello")) // cannot use Slug as type argument for T
string as a constraint names exactly one type: the predeclared string. A defined type built on it is a different type and fails the check. The fix is the approximation term, which describes a set by underlying type:
func Clean[T ~string](s T) T {
return T(strings.ToLower(string(s)))
}
_ = Clean(Slug("Hello")) // accepted: Slug's underlying type is string
This is the mental model that makes constraints readable: a constraint interface is a type set. Its type elements say which types may be passed; its method elements (if any) say what those types must implement. The vertical bar unions sets:
type Numeric interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
type Stringish interface {
~string | ~[]byte
}
Rather than hand-write that enumeration, reach for the standard library's: cmp.Ordered is the set of types legal for < (integers, floats, and string, including defined types over them), and it is exactly what a Min or a sort needs. The two built-ins round out the common cases: any is every type — use it when the body needs no operations — and comparable is every type supporting == and !=, which is what slices.Index and a Dedupe helper demand.
Three rules of the notation surface in compiler errors sooner or later. A term inside a union may not be a type parameter. Union terms must not overlap (~int | MyInt is illegal because MyInt sits inside ~int). And a union with more than one term cannot contain an interface that specifies methods — you may not write interface { fmt.Stringer | ~string }; method requirements and type unions live in different positions of the same constraint, not inside one another. These rules are why constraints read best as short, single-purpose sets: one set for ordering, one for comparison, one for the body's method needs.
The payoff shows up at the call site, not in the body. Clean and IndexOf and every generic function in this roadmap are checked once against their constraint; each instantiation is then a lookup in the type set. Getting the set right — ~string when defined types must pass, comparable when the body compares — is the whole job of constraint design.
The Loops You Can Stop Writing
The generic helpers from the previous topic — index, contains, map, equal — exist in the standard library, written and tested once, with constraints chosen correctly. Knowing their names removes most of the ad-hoc generic code teams otherwise write.
cmp is comparison logic. cmp.Ordered is the constraint; cmp.Compare(a, b) returns -1/0/1 for any ordered type; cmp.Less(a, b) is the boolean form. They exist so you never re-derive sign-of-a-difference for a sort comparator.
slices is the collection toolkit:
import (
"cmp"
"slices"
)
// searching: the element type must be comparable
i := slices.Index(names, "grace") // int
ok := slices.Contains(names, "ada") // bool
n := slices.IndexFunc(users, func(u User) bool { return u.ID == 7 })
// ordering: Sort needs cmp.Ordered elements; SortFunc needs any element
slices.Sort(tags) // []string in place
slices.SortFunc(users, func(a, b User) int {
return cmp.Compare(a.Age, b.Age) // three-way: -1, 0, 1
})
// reshaping
compact := slices.Compact([]int{1, 1, 2}) // [1 2]
equal := slices.Equal(a, b) // element-wise ==
idx, found := slices.BinarySearch(sorted, target) // requires ascending input
slices.DeleteFunc(pending, func(p Job) bool { return p.Cancelled })
Note the signature behind the first block: slices.Index[S ~[]E, E comparable](s S, v E) int — two type parameters where one is defined in terms of the other. The slice type arrives first and the element type follows from it, which is why slices.Index(names, "ada") needs no explicit type arguments at all.
maps handles the operations you should never loop for:
clone := maps.Clone(cfg) // shallow copy, new backing map
same := maps.Equal(a, b) // keys and values match
maps.DeleteFunc(m, func(k string, v int) bool { return v < 0 })
for k := range maps.Keys(m) { // iterator over keys (Go 1.23+)
fmt.Println(k)
}
Each of these replaces code that was subtly wrong more often than not — a Clone built with a naked loop that forgot an early continue, an Equal that compared lengths only. The tradeoff for calling them directly is that your go.mod decides which exist: slices.SortFunc and cmp arrived in Go 1.21, and maps.Keys changed from a slice-returning function to an iterator in 1.23, so a module with an older go directive compiles against the older signature. Check the directive before copying a snippet — the toolchain and modules topic is where that version gate is set.
The habit worth forming: before writing a generic loop over a slice or map, scan slices and maps first. The collection functions are the constraint-aware layer, and reaching for them keeps your own constraints down to the ones only your domain has.
Which Operators This Body Is Allowed to Use
A constraint does more than gate call sites; it decides which expressions the compiler accepts inside the function. The rule is one sentence: an operation on a type-parameter value is valid only if it is valid for every type in the constraint's type set. Applying it beats memorizing a list.
func Dot[F ~float32 | ~float64](a, b []F) F {
var sum F
for i := range a {
sum += a[i] * b[i] // ok: + and * apply to both float32 and float64
}
return sum
}
func Remainder[T int | float64](a, b T) T {
return a % b // invalid: % does not apply to float64
}
The first compiles because + and * hold for both members of the set; the second fails because one member — float64 — has no remainder operator, and the type set contains it. Adding a type to a constraint can therefore break the body: a Numeric set including floats forbids % even though every integer in it works.
Ordering follows the same derivation. a < b is accepted when the type set is ordered — cmp.Ordered is the standard spelling — and rejected under any or comparable, neither of which promises <:
func Min[T cmp.Ordered](vals ...T) T {
m := vals[0]
for _, v := range vals[1:] {
if v < m {
m = v
}
}
return m
}
Equality splits the same way: == requires every type in the set to be comparable, which is why a Dedupe[S ~[]E, E comparable] carries comparable on its element parameter rather than any. Method calls follow suit — it.String() compiles only when the constraint requires that method, as in T fmt.Stringer. Conversions, too: T(x) is permitted when every type in the set accepts the conversion, which is what lets Clean[T ~string] write T(strings.ToLower(...)).
Two derivations close the map. Built-ins with special rules — len, cap, append, close, min, max, clear — are available on appropriate type sets without appearing in the constraint. And range over a type-parameter value needs the set to share one underlying structure: ranging over S ~[]E works because every member is a slice, which is exactly how slices functions are written.
For review, the derivation is the check: list the types in the set mentally, test the operator against the weakest member, and you know whether the body compiles — before the compiler tells you, and before you widen the constraint with a type that breaks it.
Declaring Constraints That Survive Contact With Callers
Constraint declarations are API, and like all API the cost shows up later. Four decisions are worth making deliberately.
Where it lives. Declare a constraint in the package that uses it, beside the functions that need it — the same consumer-side reasoning that governs interfaces (mocks and fakes). Export it only when callers must write it in their own generic signatures; an unexported constraint used by two internal helpers documents itself without widening the package's surface.
// package report
type Temperature interface {
~float32 | ~float64
}
func Mean[T Temperature](readings []T) T { /* ... */ }
Callers rarely name Temperature — report.Mean(samples) infers T — so exporting it early locks in a union you may want to widen.
How wide the set is. Start narrow: any when the body needs nothing, comparable when it compares, cmp.Ordered when it orders. Each widening admits more call sites and constrains the body more — remember that adding float64 to a numeric set forbids % everywhere it is used. The reverse tradeoff is real too: a set of exactly int | int64 forces callers with int8 to convert by hand. The rule of thumb is to widen only when a body operation demands it and narrow only when an operation requires it.
Union extension is a compatibility decision. Adding a term to a constraint's union lets more types in — callers already passing the old terms are unaffected, so widening is safe. Removing a term breaks every caller who passed it. Public constraints in libraries should therefore be conservative unions documented as extensible, and never a snapshot of today's needs.
When the constraint is the wrong tool. A type set selects types at compile time; it cannot dispatch at runtime or hold mixed values in one slice. If the caller needs a heterogeneous collection, plugin lookup, or behavior chosen per value, the contract is an ordinary interface with methods — http.Handler did not become generic for a reason. And if the body would immediately type-switch back to two concrete cases, neither mechanism earned its keep; a plain function with an interface parameter stays the honest answer.
The test for any constraint you are about to export: can a caller pass a type that satisfies the set but produces nonsense? If yes, the set is too wide; if reasonable callers must convert first, it is too narrow. With functions, constraints, and collections in hand, the remaining generics territory is where the language says no — generic types, methods, and their limits.
Quiz
1. A helper accepts `type Slug string` values, but `func Clean[T string](s T)` rejects them at the call site. What is the correct constraint?
2. Why does `func R[T int | float64](a, b T) T { return a % b }` fail to compile while `return a + b` in the same function succeeds?
3. You need to sort []User by Age, where User is a struct with no ordering of its own. Which call is correct?
4. You define `type Numeric interface { ~int | ~float64 }` and publish it in a library. Later, users need int64 readings. What is the compatibility effect of adding `~int64`?
Flashcards
Question
What is the difference between the constraint `string` and `~string`?
Click to reveal answer
Answer
`string` is a one-element set containing only the predeclared type, so `type Slug string` cannot be passed. `~string` is the set of every type whose underlying type is string, which admits Slug and any other defined string type — the usual intent for text-processing helpers.
Question
How do you decide whether a given operator is legal inside a generic function?
Click to reveal answer
Answer
List the constraint's type set and test the operator against every member: valid for all of them means legal in the body. `+` passes for int|float64; `%` fails because float64 lacks it; `<` requires an ordered set (cmp.Ordered); `==` requires every member to be comparable; method calls require the method in the constraint.
Question
When does slices.Sort apply, and when do you need slices.SortFunc?
Click to reveal answer
Answer
Sort handles elements whose type is cmp.Ordered — ints, floats, strings, and defined types over them. Anything else, including structs, needs SortFunc with a three-way comparator (cmp.Compare on a field, or a custom func returning -1/0/1). SortFunc also works for ordered types when the ordering is not the natural one.
Question
What does the double type parameter in slices.Index[S ~[]E, E comparable] mean, and how are its type arguments chosen?
Click to reveal answer
Answer
S is any slice type and E its element type, defined in terms of S, with E required to be comparable. Passing a []string supplies both at once — S as []string, E as string — so inference needs no explicit type arguments. This S ~[]E pattern is the standard shape for generic slice operations.
Question
When should a constraint NOT be part of your public API?
Click to reveal answer
Answer
When callers never name it — inference supplies T for functions like Mean, so an unexported constraint keeps the union private and changeable. Export it only when callers must write it in their own generic signatures, because a published union is then compatibility surface: widening is safe, removing a term breaks source compatibility.
Revision Notes
Key Takeaways
- 1. A constraint is a type set plus optional method requirements; ~T admits defined types while a bare T admits only the predeclared one
- 2. Body operations are checked against every member of the set — widening a union can break the function body, not just loosen the call site
- 3. cmp.Ordered, slices, and maps replace the generic loops you would otherwise write; Sort vs SortFunc follows element ordering, not taste
- 4. The S ~[]E signature pattern lets inference derive the element type from the slice type at every call
- 5. Constraints are API: export only what callers must name, treat unions as append-mostly, and keep runtime dispatch with interfaces
Interview Tips
- • "Write a generic Min" — `func Min[T cmp.Ordered](vals ...T) T` and explain that `<` is legal because every type in Ordered supports it; be ready for "what if the set included float64 and you needed %?"
- • "What's the difference between string and ~string?" — the set: one type vs all defined types over it; give `type Slug string` as the caller that separates them
- • "When does a constraint change break a body?" — when the operation stops applying to a new member: adding float64 to a numeric set outlaws `%`
- • "Show me how you'd sort structs" — slices.SortFunc with a three-way comparator, and say why slices.Sort's cmp.Ordered requirement excludes structs
Cheat Sheet
Constraints and Generic Collections
Constraint notation:
| Form | Meaning | Typical use |
|---|---|---|
any |
every type | pass-through bodies, storage |
comparable |
types with == and != | Dedupe, map keys, slices.Index |
~T |
underlying type is T | admit defined types (~string) |
A | B |
union of sets | numeric or stringish domains |
cmp.Ordered |
everything legal for < |
Min, Sort, SortFunc keys |
| method element | method must exist | T fmt.Stringer bodies |
Operator derivation: valid for every type in the set => legal in the body. + OK for int|float64; % NOT; < needs Ordered; == needs comparable; len/cap/append/min/max follow built-in rules; range needs one shared underlying structure (S ~[]E).
Restrictions: union terms cannot be type parameters or overlap; a multi-term union cannot contain method-specifying interfaces (fmt.Stringer | ~string is illegal).
stdlib calls:
slices.Index(s, v) // [S ~[]E, E comparable]
slices.Contains(s, v) // bool
slices.Sort(v) // cmp.Ordered elements, in place
slices.SortFunc(s, cmp.Compare) // any elements + three-way comparator
slices.Equal(a, b) / slices.Compact(s)
maps.Clone(m) / maps.Equal(a, b) / maps.DeleteFunc(m, f)
for k := range maps.Keys(m) { ... } // Go 1.23+ iterator
API rules: declare constraints unexported until a caller must name them; widening a union is backward compatible, removing a term is not; start narrow (any/comparable/Ordered) and widen only when a body operation demands it.
Continue Learning
Before You Start
- No prerequisites listed.