Type parameters and constraints: type sets, inference limits, and GCShape stenciling
Type parameters with constraints as type sets: ~T approximation, unions, comparable. Where inference works and where it stops. Instantiation via GCShape stenciling — value types get their own machine code, all pointer types share one instance plus a runtime dictionary.
When Go 1.18 landed, the storage team rewrote their interface{}-based LRU cache as Cache[K comparable, V any]. Type assertions vanished from the hot path, and the design doc promised the speedup the team remembered from C++ templates: full monomorphization, everything inlined. The benchmarks came back split down the middle. Cache[string, int64] ran 28% faster than the old code. The instantiation production actually used — Cache[string, *Session] — measured within noise of the interface{} version. The disassembly explained why: every pointer-typed instantiation had compiled to one shared function taking a hidden dictionary argument, and the calls inside it were still indirect. Go generics are not C++ templates; they are a deliberate hybrid, and the hybrid’s seam runs exactly between value types and pointer types. The team kept the generic API — it deleted a whole class of assertion panics — but the performance slide quietly disappeared from the deck.
In ten minutes you will know exactly where the seam runs — and why the pointer case silently swallowed the expected speedup.
Constraints are type sets, not class hierarchies
A type parameter list — func Sum[T Number](xs []T) T — introduces T as a placeholder that must satisfy its constraint. A constraint is an ordinary interface, but used in a new role: instead of describing what a value can do, it describes the set of types allowed. Inside that interface you can list types directly, join them with | into unions, and prefix them with ~ to mean “any type whose underlying type is this”:
// A constraint is an interface used as a type set.
type Number interface {
~int | ~int64 | ~float64 // ~T: any type whose underlying type is T
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x // legal: every type in the set supports +
}
return total
}
type Celsius float64 // named type, underlying type float64
var _ = Sum([]Celsius{20.5, 21.0}) // compiles only because of the ~The ~ matters more than it looks. Go code is full of named types — type UserID int64, type Port int — and a constraint written as a bare int64 admits only int64, not UserID. Without the tilde your “numeric” library rejects half the codebase’s domain types, and the failure is a compile error at the caller, far from the constraint that caused it. The rule of thumb: in reusable constraints, always write ~T unless you have a specific reason to exclude named types. Two more pieces complete the vocabulary: comparable is a predeclared constraint meaning “supports == and !=” (it is what map keys need, and it cannot be expressed as a union — the set is infinite), and constraints can also carry methods, in which case a type must both be in the set and have the methods. What a constraint can never do is act as an ordinary type: you cannot declare var n Number — type sets exist only at instantiation time.
Inference: powerful at call sites, absent everywhere else
Before you rely on inference to keep call sites clean, it helps to know where it simply stops — so you can spot the design smell before a teammate trips over it.
Type inference is what makes generics readable: Sum(prices) instead of Sum[Celsius](prices). The compiler infers type arguments from the types of the function arguments, then checks them against the constraints. But inference has hard edges, and senior code review means knowing where they are:
func Map[T, U any](xs []T, f func(T) U) []U { /* ... */ }
ys := Map(ids, strconv.Itoa) // T=int, U=string — inferred from arguments
func Zero[T any]() T { var z T; return z }
v := Zero[int]() // explicit instantiation required:
// T appears only in the result, inference has no input to look atA type parameter that appears only in return position can never be inferred — there is no argument carrying the information. Untyped constants are the second trap: passing 1 to a [T Number] parameter infers int (the constant’s default type), not int64, and if the constraint excludes int the call fails even though 1 is a perfectly valid int64. The third limit is structural: inference works at call sites only. There is no inference from assignment context, no partial specialization, no overloading on constraints. When inference fails, the fix is mechanical — write the type argument explicitly — but an API that routinely forces callers to write F[concrete](...) is a design smell worth fixing by moving a type parameter into an argument.
A library defines the constraint type ID interface { int64 } — no tilde. A teammate writes type UserID int64 and calls Lookup[UserID](...). What happens?
Instantiation: GCShape stenciling, honestly
What happens when you actually call Sum[Celsius]? C++ would monomorphize: emit fresh machine code for every type argument — fast code, slow compiles, big binaries. Java erases: one copy of the code, everything boxed. Go 1.18 chose a midpoint called GCShape stenciling with dictionaries. The compiler groups type arguments by GC shape: two types share a shape if they look identical to the memory allocator and garbage collector — same size, same pointer layout. int64, time.Duration, and Celsius-over-float64 are different shapes (well, int64 and Duration share one); but the big collapse is this: every pointer type is one shape. *User, *Order, *bytes.Buffer — one shared instantiation. To tell them apart at runtime, the compiler passes a hidden dictionary argument holding the concrete type’s metadata: its runtime._type, its method addresses, derived types. Code that calls a method on the type parameter loads the address from the dictionary and calls indirectly — which also means no inlining across that call.
The honest performance consequences: value-type instantiations get dedicated code, direct calls, full inlining — real wins over interface{} versions that had to box and assert. Pointer-type instantiations get shared code and dictionary-indirect calls — roughly the cost profile of an interface method call, sometimes a touch worse because the dictionary adds a load. That is exactly the seam the Hook’s team hit: Cache[string, int64] monomorphized; Cache[string, *Session] went through the dictionary. Stenciling is an implementation detail the spec does not promise — gc’s strategy has stayed shape-based since 1.18 — but designing APIs as if generics were free monomorphization is how benchmark surprises get into production reviews.
You instantiate Cache[string, *User] and Cache[string, *Order]. How many copies of the cache's machine code does gc emit for the value type, and how do calls on it dispatch?
- 01Explain constraints as type sets: what do ~T, unions, and comparable each contribute, and why does a constraint written as bare int64 reject type UserID int64?
- 02Walk through GCShape stenciling: what gets its own machine code, what shares, what the dictionary does, and the performance consequence the team in the Hook hit.
Generics in Go are two ideas bolted together, and both have edges. The first idea is the constraint as a type set: an interface that lists types instead of methods, where ~T admits every type with underlying type T (forget the tilde and every type UserID int64 in the codebase bounces off your library), unions enumerate alternatives, comparable names the infinite set of ==-capable types, and the body of the generic function may use only the operations every member of the set supports. Constraints are compile-time-only citizens — they cannot be the type of a variable. The second idea is the compilation strategy: type inference fills in type arguments from call-site arguments and nothing else, so a parameter appearing only in results needs explicit instantiation, and untyped constants infer their default types. Instantiation then proceeds per GC shape, not per type — GCShape stenciling. Distinct value shapes get dedicated machine code with direct, inlinable calls; every pointer type collapses into a single shared instantiation parameterized by a hidden dictionary of type metadata, making calls on the type parameter indirect. That seam is the performance story to remember: generic code over value types can genuinely beat interface{}-based code, while generic code over pointers usually ties it, as the cache team’s split benchmark showed. Use generics for type safety and API clarity everywhere they fit — but promise monomorphization speedups only where the type arguments are values. Now when you see a generic API in a benchmark that underperforms the interface{} version, check the type argument first: if it is a pointer, you already know why.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.