Generic Constraints Deep Dive — Junior Level¶
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- Core Concepts
- Real-World Analogies
- Mental Models
- Pros & Cons
- Use Cases
- Code Examples
- Coding Patterns
- Clean Code
- Product Use / Feature
- Error Handling
- Security Considerations
- Performance Tips
- Best Practices
- Edge Cases & Pitfalls
- Common Mistakes
- Common Misconceptions
- Tricky Points
- Test
- Tricky Questions
- Cheat Sheet
- Self-Assessment Checklist
- Summary
- What You Can Build
- Further Reading
- Related Topics
- Diagrams & Visual Aids
Introduction¶
Focus: "What is a constraint, really, and why is it an interface?"
When you write func F[T any](x T) the bracketed thing — T any — is a type parameter declaration. The word any is the constraint. Many beginners think a constraint is some new kind of language feature: a "constraint thing" that limits what T can be. The truth is simpler and more surprising:
A constraint is just an interface.
Go did not invent a new "constraint" concept for generics. Instead, the language extended interfaces so that they can carry type sets in addition to method sets. Once you internalise this idea, every other constraint pattern in Go falls into place.
// "any" is literally an interface
type any = interface{}
// "comparable" is a special predeclared interface
type comparable interface { /* opaque */ }
// Your own constraints — also interfaces
type Number interface {
~int | ~float64
}
After reading this file you will: - Know that a constraint is an interface (no exceptions). - Understand the type set that a constraint defines. - Be able to read [T comparable], [T any], [T int | string]. - Know the difference between a method-bearing constraint and a type-bearing one. - Recognize the two predeclared constraints: any and comparable.
This file stays at the surface. The next files (middle.md, senior.md) drill into the deeper machinery.
Prerequisites¶
- Comfortable Go syntax: variables, slices, maps, basic structs.
- Familiarity with interfaces as method sets.
- Read
../04-type-constraints/junior.md(the introductory constraint tour) or be at ease with[T any],[T comparable]. - Go 1.18+ for the basic feature; 1.21+ for stdlib
cmp.Ordered.
Glossary¶
| Term | Definition |
|---|---|
| Constraint | The interface that limits which types a type parameter accepts |
| Type parameter | A placeholder name (T, K) introduced inside [ ] |
| Type set | The set of types that satisfy a given constraint |
| Type element | A type term int, string, ~float64 inside an interface |
| Method element | A method signature Read(p []byte) (int, error) inside an interface |
| Union | A \| B — a type element listing several alternatives |
| Underlying type | The "raw" type behind a defined name; type Age int has underlying int |
~T | A term for any type whose underlying type is T |
any | Predeclared alias for interface{} — type set is "all types" |
comparable | Predeclared interface — type set is "all strictly comparable types" |
| Strictly comparable | A type for which == is well-defined for every value |
| Implementation | A type satisfies an interface if it is in the interface's type set |
Core Concepts¶
1. A constraint is an interface¶
Go's spec says it plainly:
A type constraint is an interface that defines the set of permissible type arguments.
So whenever you see a constraint, ask "where is the interface?" — there is always one, even if it is anonymous:
// Named constraint (interface declared at package scope)
type Number interface {
~int | ~float64
}
func Sum[T Number](s []T) T { ... }
// Inline constraint (anonymous interface inside the brackets)
func Sum2[T interface{ ~int | ~float64 }](s []T) T { ... }
// Shorthand when the constraint is a single type element — Go 1.18+
func Sum3[T ~int | ~float64](s []T) T { ... }
All three are equivalent. The third form is just sugar for the second.
2. What an interface carries¶
A constraint-interface can contain:
| Element | Example | Meaning |
|---|---|---|
| Method element | String() string | Type must have this method |
| Type element (single) | int | Type must be exactly int |
Type element (~) | ~int | Underlying type must be int |
| Union | int \| string | Either of the listed terms |
| Embedded interface | error | All requirements of error apply |
A constraint can mix methods and types:
This means: the type's underlying type is int and the type has a String() string method.
3. Type sets — the unifying idea¶
Every interface has a type set: the set of types that satisfy it.
type any = interface{}
// Type set: every type in the language
type comparable interface { /* opaque */ }
// Type set: every strictly comparable type
type Number interface { ~int | ~float64 }
// Type set: int, float64, and all defined types whose underlying is int or float64
A type X satisfies the constraint when X is in the type set. That is the only rule. There is nothing else.
4. The two predeclared constraints¶
Go ships with two built-in constraints:
// any — every type passes
func Identity[T any](v T) T { return v }
// comparable — types usable with == and !=
func Eq[T comparable](a, b T) bool { return a == b }
any is literally interface{}. comparable is special: you cannot redefine it, and it has been "loosened" since Go 1.20 (more on that in senior.md).
5. Why use a constraint at all?¶
If you write [T any], the body cannot use ==, <, or any method. The constraint is what unlocks operations:
// Allowed because comparable unlocks ==
func In[T comparable](xs []T, t T) bool {
for _, x := range xs { if x == t { return true } }
return false
}
// Not allowed — any does not unlock ==
func In2[T any](xs []T, t T) bool {
for _, x := range xs { if x == t { return true } } // compile error
return false
}
The constraint is a contract between you and the compiler: "I promise the caller's type supports these operations; in exchange, let me write the body using them."
Real-World Analogies¶
Analogy 1 — Job posting
A job posting says "must have a driver's licence". That is a constraint. It does not say which car you will drive — only that you can drive any car that needs a driver. comparable is the same: "must support ==". It does not say which type — only that the type supports the operation.
Analogy 2 — Power outlet
A "Type C" outlet (the round European plug) accepts only Type-C plugs. The outlet is a constraint; the plug is the concrete type. [T ~int] is a "Type-int" outlet — any plug whose underlying shape is int fits.
Analogy 3 — Library card
A library card is a constraint: "anyone with this card can borrow books". The library does not care who you are — only that you have the card. Likewise, the body of a generic function does not care what T is, only that T satisfies the constraint.
Analogy 4 — Recipe ingredients
A recipe says "use any flour". That any flour is a union — wheat, rice, almond — but flour, not sugar. int | float64 | string is the same: a union of allowed alternatives.
Mental Models¶
Model 1 — "Constraint = filter on the universe of types"¶
The universe contains every Go type. A constraint is a filter. The filter passes some types and rejects others. The filtered set is the type set.
Universe → Filter (constraint) → Type set
{int, string, []byte, ...} ~int|~float64 {int, float64, Celsius, MyFloat, ...}
Model 2 — "Constraint = interface with extra power"¶
A regular interface filters on methods. A constraint-interface also filters on types. Same mechanism, more keys to filter on.
Model 3 — "Body is a contract"¶
Read the body before reading the constraint. What operations does the body need? +, ==, <, len(s)? Each operation requires the constraint to authorise it. A loose constraint plus a strict body is a compile error.
Model 4 — "Two ways to satisfy"¶
A type X satisfies an interface in two ways:
- By type —
Xis mentioned in a type element (or its underlying type matches a~term). - By methods —
Xhas all the methods listed.
A constraint can require both. Some constraints require only types, some only methods, some both.
Pros & Cons¶
Pros¶
| Benefit | Why it matters |
|---|---|
| One language feature, not two | Constraints are interfaces; nothing new to learn structurally |
| Composable | Embed an interface inside another to combine constraints |
| Documented at the type level | A reader sees the constraint right next to the function name |
| Compile-time check | A wrong type argument fails at compile time, not runtime |
| Predeclared options | any and comparable cover most basic needs |
Cons¶
| Drawback | Why it matters |
|---|---|
| Two roles for interfaces | The same syntax means "method set at runtime" and "constraint at compile time" |
| Type sets are abstract | New users find type-set arithmetic non-obvious |
comparable is special | It does not behave exactly like a normal interface |
| No "negative" constraints | You cannot say "any type that is not a slice" |
| Constraints proliferate | Easy to invent a one-off constraint for every helper |
Use Cases¶
Constraints shine when you need to:
- Permit only numeric types —
~int | ~int64 | ~float64. - Allow only ordered types —
cmp.Ordered. - Allow only comparable types —
comparable. - Require a method —
interface { String() string }. - Require both a type shape and a method —
~int; String() string. - Express domain types —
~UUID,~OrderID.
You do not need a constraint when:
- The body works for any type (
anyis fine). - The constraint becomes harder to read than three duplicate functions.
Code Examples¶
Example 1 — any and comparable¶
package main
import "fmt"
func Last[T any](s []T) T {
var zero T
if len(s) == 0 { return zero }
return s[len(s)-1]
}
func IndexOf[T comparable](s []T, target T) int {
for i, v := range s {
if v == target { return i }
}
return -1
}
func main() {
fmt.Println(Last([]int{1, 2, 3})) // 3
fmt.Println(IndexOf([]string{"a","b"}, "b")) // 1
}
Example 2 — A union constraint¶
type IntegerLike interface {
int | int32 | int64
}
func Triple[T IntegerLike](v T) T { return v * 3 }
This rejects float64, string, and Celsius (because ~int would be needed to admit Celsius).
Example 3 — A method-only constraint¶
type Stringer interface {
String() string
}
func Describe[T Stringer](xs []T) []string {
out := make([]string, len(xs))
for i, x := range xs { out[i] = x.String() }
return out
}
This is just a regular interface used as a constraint. Nothing new.
Example 4 — Mixed constraint¶
type IntStringer interface {
~int
String() string
}
type UserID int
func (u UserID) String() string { return fmt.Sprintf("user/%d", int(u)) }
func Tag[T IntStringer](v T) string { return v.String() }
The constraint requires both an integer underlying type and a String method.
Example 5 — Predeclared comparable in a map helper¶
func GroupBy[T any, K comparable](items []T, key func(T) K) map[K][]T {
out := make(map[K][]T)
for _, item := range items {
k := key(item)
out[k] = append(out[k], item)
}
return out
}
K must be comparable because Go map keys must be. T does not need any constraint — it is just data.
Example 6 — Constraint inline vs named¶
// Inline (one-shot)
func Add1[T interface{ ~int | ~float64 }](a, b T) T { return a + b }
// Named (reusable)
type Numeric interface { ~int | ~float64 }
func Add2[T Numeric](a, b T) T { return a + b }
Both compile to the same thing. Prefer named constraints when the same shape is reused.
Coding Patterns¶
Pattern 1 — Pick the loosest constraint¶
Start with any. Tighten only when the body needs more. Last[T any] is fine. IndexOf[T comparable] is needed because the body uses ==.
Pattern 2 — Name shared constraints at package scope¶
Reuse Numeric across many helpers in the same package.
Pattern 3 — Inline tiny one-off constraints¶
If a constraint is used only once and is short, inline it:
Pattern 4 — Embed for composition¶
Hashable requires both built-in equality and a custom hash method. Embedding comparable reuses its type set.
Clean Code¶
- Name constraints with intent:
Number,Stringer,OrderID— notT1,Cons1. - Use single uppercase letters for type parameters (
T,K,V,E). - Keep constraint names short when they are widely used (
NumericoverNumericalSummableType). - Group constraints in one file (
constraints.go) when the package has several. - Document non-obvious constraints with a one-line comment.
// Numeric covers all built-in numeric types and any user-defined
// numeric types whose underlying type is one of them.
type Numeric interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64
}
Product Use / Feature¶
Real product scenarios:
- Money math —
[T Currency]constraint for typed amounts. - Identifiers —
[T ~int64 | ~string]for ID fields. - Aggregations —
[T Numeric]forSum,Avg,Min,Maxhelpers. - Caching —
[K comparable, V any]for typed caches. - Validation —
[T Validatable]where the constraint demands aValidate() errormethod.
Each constraint is a contract that the product feature can rely on at compile time.
Error Handling¶
Constraints do not change Go's error model. They only change which types are allowed at compile time. There is no "constraint error" at runtime — a wrong type is rejected during compilation:
func Sum[T Numeric](s []T) T { ... }
Sum([]string{"a", "b"}) // compile error: string does not implement Numeric
The runtime behaviour of the body is unchanged. Errors inside the body still flow through error returns as usual.
Security Considerations¶
anydoes not validate input. Receivinganyparameters from untrusted sources still requires runtime checks.comparableinterfaces can panic at runtime in 1.20+ if the dynamic type is not really comparable (more insenior.md).- A leaky constraint exposes internal types.
[T MyInternal]makesMyInternalpart of the public API.
Performance Tips¶
- Method-bearing constraints dispatch through the runtime dictionary (mostly invisible cost).
- Type-bearing constraints (no methods) compile to a stenciled body and are usually as fast as hand-written code.
- A union of many disparate types may produce more dictionary entries; benchmark hot paths.
For the deep performance discussion see optimize.md.
Best Practices¶
- Always remember: a constraint is an interface.
- Use
anyfirst; tighten only when needed. - Reuse stdlib constraints —
comparable,cmp.Ordered. - Name and place reusable constraints at package scope.
- Keep constraints small — under 10 type elements is a good upper bound.
- Document
~TvsTchoices — they look similar, behave differently. - Do not repurpose runtime interfaces as constraints if they have many methods you do not need.
- Test with at least two type arguments to confirm the constraint is correct.
Edge Cases & Pitfalls¶
1. Methods can appear in a constraint, but the constraint body must still be an interface¶
// OK: interface
type C interface { ~int; String() string }
// Not OK: not an interface
// type C ~int; String() string ← not legal
Constraints must be interface types.
2. comparable is not the same as cmp.Ordered¶
comparable allows == and != only. cmp.Ordered adds <, <=, >, >=. Mixing them up is a classic beginner bug.
3. ~T requires a non-interface T¶
You cannot put ~SomeInterface in a constraint.
4. Constraints have no value-level meaning¶
A constraint is for type-checking, not for storing values. Use a normal interface for runtime polymorphism.
5. Empty type set is allowed but useless¶
type Impossible interface { int; string } // intersection of {int} and {string} is empty
func F[T Impossible]() {} // compiles, but F can never be called
The compiler does not flag this. It is a logic bug.
Common Mistakes¶
- Forgetting
~when you want to accept defined types. - Using
comparablewhen you really needcmp.Ordered. - Mixing methods and unions without realising the methods apply to every type in the union.
- Giant unions —
~int | ~int8 | ~int16 | ...instead of importing a stdlib constraint. - Treating a constraint as a runtime interface — storing values in it, calling its methods directly.
Common Misconceptions¶
- "Constraints are a new feature." No — they are interfaces with extra elements.
- "
anyis different frominterface{}." It is an alias. - "A constraint can rule out specific types." No — Go has no negative constraints.
- "
comparableincludes slices." No — slices are not strictly comparable. - "Constraints are checked at runtime." No — purely compile-time.
Tricky Points¶
- The
~operator only works on non-interface types — you cannot write~io.Reader. - A union with methods means each term must have those methods — see
middle.md. anyandinterface{}print the same infmt; the alias is purely cosmetic.comparablewas loosened in 1.20 — interface types now satisfy it (with possible runtime panic).- Anonymous constraints are legal but rarely a good idea past trivial cases.
Test¶
- What is a constraint, structurally?
- Name the two predeclared constraints.
- What is a type set?
- What does
~intmean in a constraint? - What does
int | stringmean? - What operations does
[T any]allow inside the body? - What operations does
[T comparable]allow? - Can a constraint contain methods?
- Can a constraint contain both methods and types?
- Why is a constraint always an interface?
(Answers: 1) an interface; 2) any, comparable; 3) the set of types satisfying the constraint; 4) any type whose underlying type is int; 5) int or string; 6) only operations not requiring type knowledge — assignment, return, range; 7) ==, !=; 8) yes; 9) yes; 10) the spec defines constraints as interfaces.)
Tricky Questions¶
Q1. Why does this compile?
A.~int puts T in a type set where + is defined. The compiler permits + because every member of the set supports it. Q2. Why does this not compile?
A. Because+ is defined for both int and string, but the compiler also requires the operations to behave uniformly. In Go 1.18+ this does compile if both types support the same operator with the same semantics — and + does for int (addition) and string (concatenation). The result is a perfectly valid generic function. (This is a famous tricky case — read carefully.) Q3. Is any a constraint or an alias? A. Both. type any = interface{}. The alias is the empty interface; the empty interface as a constraint allows every type.
Q4. Can comparable be used as a normal runtime interface? A. No. The compiler treats it specially. You cannot write var x comparable = 1 and pass it around as you would var x any.
Q5. What is the type set of interface{ int; string }? A. Empty. The intersection of {int} and {string} is empty. A function with this constraint cannot be instantiated.
Cheat Sheet¶
// 1. The two predeclared constraints
[T any] // every type
[T comparable] // types usable with == / !=
// 2. Type elements
[T int] // exactly int
[T ~int] // any type with underlying int
// 3. Unions
[T int | string]
[T ~int | ~float64]
// 4. Method elements
[T interface{ String() string }]
// 5. Mixed
[T interface{ ~int; String() string }]
// 6. Named constraint
type Numeric interface { ~int | ~float64 }
[T Numeric]
| Looks like | Actually is |
|---|---|
any | interface{} |
comparable | special predeclared interface |
~int | type element with tilde |
int \| string | union of two type elements |
T C | T must be in C's type set |
Self-Assessment Checklist¶
- I can state that a constraint is an interface.
- I can name the two predeclared constraints.
- I can explain what a type set is.
- I can read
[T ~int | ~float64]. - I know the difference between
intand~intin a constraint. - I have written at least one named constraint at package scope.
- I know
comparableis not the same ascmp.Ordered. - I understand that constraints are checked at compile time, not runtime.
If you ticked at least 6 boxes, move on to middle.md.
Summary¶
A constraint in Go is a regular interface, extended with the ability to list type elements alongside method elements. Each interface defines a type set — the set of types that satisfy it. The two predeclared constraints, any and comparable, cover the most common needs. Custom constraints are declared exactly like interfaces.
The mental shortcut is: "constraint = interface that may contain types." Once you accept this, everything else — ~T, unions, mixed constraints, the type-set algebra — is just notation on top of the interface mechanism you already understand.
The next file (middle.md) drills into the meat of the system: ~, unions, methods plus types, and the practical patterns these enable.
What You Can Build¶
After this section you can build:
- A
Numericconstraint plusSum,Avg,Min,Maxover it. - A
Stringer-constrained pretty-printer. - A typed identifier helper (
UserID,OrderID) constrained by~int64. - A constraint-driven validator demanding a
Validate() errormethod. - A typed-key cache with
[K comparable, V any].
Further Reading¶
- The Go Spec — Type constraints
- Type Parameters Proposal
cmppackagegolang.org/x/exp/constraints- An Introduction To Generics — Go blog
Related Topics¶
- 4.4 Type Constraints — the introductory tour
- 4.6 Generic Constraints Deep Dive — this file
- 4.13 Comparable and Ordered — the two flagship constraints in detail
- 3.2 Interfaces — runtime interface mechanics
- 4.2 Generic Functions — the syntax around the brackets
Diagrams & Visual Aids¶
A constraint, schematically¶
+---------------------------------+
| Interface (constraint) |
| |
| methods: String() string |
| types: ~int | ~int64 |
| |
| Type set: { all defined types |
| whose underlying is int or |
| int64 AND that have String |
| method } |
+---------------------------------+
How a constraint authorises operations¶
Constraint says: "T is comparable"
Body uses: a == b
Compiler: OK — comparable authorises ==
Constraint says: "T any"
Body uses: a == b
Compiler: ERROR — any does not authorise ==