Generics, and when they earn it
For thirteen years Go shipped without generics — the single most-requested feature in its history — and the team said no, repeatedly, on purpose. This lesson is the clearest case study in the whole series of the bargain at work: not "Go finally caved," but "the cost of the absence finally exceeded the cost of the presence, and only then, and only for a deliberately limited design." You will learn what type parameters are, the three tools they compete with, and — most importantly — the decision rule for when generics are the right complexity to buy.
io.Reader) is Go's primary tool for "write code once, run it against many types." Generics are the tool for the cases that interface tool cannot reach. You also need interfaces themselves (Lesson 05) and the value/pointer cost intuition from Lessons 02–03.New capability: write a correctly-constrained generic function or type, explain why Go resisted generics for thirteen years in terms of the bargain, and state the decision rule that chooses between generics, interfaces, and copy-paste.
any could not solve — the problem generics were finally admitted to fix. (2) The syntax, motivated: type parameters, constraints, and what the compiler actually does with them. (3) The thirteen-year "no" — what the team was protecting and what finally changed the math. (4) The decision rule: generics vs. interfaces vs. copy-paste, as an explicit complexity-budget calculation.1 · The pain interfaces could not reach
Suppose you want a function that returns the larger of two values. For int it is four lines. For float64 it is the same four lines with a different type. For string, again. Before 2022, Go gave you exactly three ways to avoid writing that function N times, and all three were bad in a specific, instructive way.
Option A — copy-paste. Write MaxInt, MaxFloat64, MaxString. The bodies are identical; only the type changes. This is honest and fast and the reader understands each function instantly — but a bug fixed in one is a bug still live in the other two, and the standard library's math package really did ship Max for float64 only, leaving everyone to hand-roll the integer version for years.
Option B — interface{} (now any). Accept any, type-assert your way back to a concrete type at runtime. This compiles, but it throws away everything the type system knew:
func Max(a, b any) any {
// a and b could be ANYTHING. We must assert, at runtime,
// and we have lost the guarantee that they are even comparable.
return a // ...and the caller gets `any` back and must assert again.
}
x := Max(3, 5).(int) // a runtime assertion that can panic
The cost here is exact and it lands on the reader: the type checker can no longer prove the call is correct, so the proof moves to runtime, where it shows up as a panic in production instead of a red squiggle in the editor. You traded a compile-time guarantee for a runtime risk — the opposite of what Go's whole "failure in the signature" philosophy (Lesson 07) wants.
Option C — a behavioral interface. This is the right tool when it fits, and it usually does. If the operation is a behavior — "anything that can Read", "anything that can String()" — an interface expresses it perfectly and with zero new machinery (Lesson 13's io.Copy is the canonical example). But "is greater than" is not a method you can put on int — you cannot add methods to built-in types — and a container like "a stack of T" has no behavior to abstract over; it just needs to hold some type and give it back as that same type. Interfaces abstract over behavior; they cannot abstract over identity — the relationship "the type that comes out is the type that went in."
any + assertion (B)So the unmet need was narrow and real: write an algorithm or a container once, keep full compile-time type safety, and preserve the identity relationship between input and output type — for cases where no shared behavior exists to lean on. That, and only that, is what generics were admitted to do.
2 · Type parameters, and what the compiler does with them
A generic function takes type parameters in square brackets before its value parameters. Each type parameter carries a constraint — the set of types it is allowed to be, which is also the set of operations the body is allowed to use on it.
// Ordered is a constraint: the set of types that support < > <= >=.
// The standard library ships this as cmp.Ordered (Go 1.21).
type Ordered interface {
~int | ~int64 | ~float64 | ~string // (abbreviated; the real one lists all)
}
func Max[T Ordered](a, b T) T { // T is a type parameter constrained to Ordered
if a > b { // legal ONLY because Ordered guarantees >
return a
}
return b
}
m := Max[int](3, 5) // explicit
m := Max(3, 5) // T inferred as int — usual case
Two details that are doing real work. First, the constraint is an interface — Go reused the interface concept rather than inventing a new one (the bargain: don't add a concept you can stretch an existing one to cover). But it is an interface used in a new position: instead of listing methods a type must have, it can list a type set with the | union and the ~ token. ~int means "any type whose underlying type is int" — so your type Celsius int still satisfies it. Second, the body may use only operations the constraint guarantees: inside Max you may write a > b precisely because Ordered promises every member supports it, and you may not call a method or add the values unless the constraint promises that too. The constraint is a contract checked once, at the definition, not at every call.
What the compiler emits. This is where Go's choice differs sharply from both C++ and Java, and the cost lands here. Go uses a hybrid strategy called GC shape stomping (or "dictionaries + stenciling"): the compiler generates one specialized copy of the function per distinct GC shape — roughly, per memory layout — not per type. All pointer-shaped types (*T, and types that are a single pointer wide) share one generated instantiation and are told their concrete type through a hidden dictionary argument; distinct value layouts (e.g. int vs a large struct) get their own copies.
| Strategy | What it generates | Cost it pays |
|---|---|---|
| C++ templates | One full copy per concrete type, monomorphized | Fastest code; large binaries & long compiles (code bloat) — the C++ track's lesson |
| Java generics | Nothing — type-erased to Object, boxed at runtime | Tiny binary; boxing allocations & runtime casts; List<int> can't even exist |
| Go (GC shape) | One copy per memory layout + a dictionary for the rest | A middle bet: bounded binary growth, a small indirection through the dictionary, and a measurable per-call overhead vs. a monomorphized C++ template |
The practical consequence to state honestly: a generic function in Go is usually slightly slower than the hand-written monomorphic version, because of the dictionary indirection and because the shared instantiation can inhibit inlining — benchmarks have shown a generic Max failing to inline where the concrete one did. Generics in Go buy you code reuse and type safety, not speed. If your reason for reaching for them is performance, you have the wrong reason — that is exactly the trap the next section is about.
A generic type follows the same shape — the container case interfaces could never reach:
type Stack[T any] struct { // T any: no operations needed, we just hold T
items []T
}
func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
func (s *Stack[T]) Pop() (T, bool) {
var zero T // the zero value of T, whatever T is
if len(s.items) == 0 {
return zero, false
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, true // returns a T — the SAME type pushed
}
The constraint any is honest here: a stack uses no operation on its elements, so it promises none. And Pop returns a T — the caller of Stack[string] gets a string back with no assertion, the identity guarantee interfaces could not give.
3 · The thirteen-year "no" — what was being protected
Generics were the top request on Go's survey almost every year from the language's 2009 debut. The team built and discarded several designs. The reason it took until Go 1.18 in March 2022 is the single most important thing to understand about this feature — and it is the bargain in its clearest form.
The cost the team refused to pay was not implementation difficulty. It was the cost generics impose on the reader of the language as a whole. Generics are uniquely dangerous to a "readable at scale" language because they are an attractive nuisance: once present, they invite programmers to build elaborate type-level abstractions — the deep, parameterized class hierarchies and template metaprogramming that make some C++ and Scala codebases legible only to their authors. Go's bet was that the average codebase is better off with a little copy-paste than with a clever generic abstraction the next engineer must decode. Recall the proverb the series keeps returning to: "A little copying is better than a little dependency" — and a generic abstraction is a dependency on a concept.
math.Max(float64)-only embarrassment, reflection-based hacks that were slow and unsafe — that the absence's cost was now larger than a carefully limited generics design's cost. (2) A design was finally found whose power was deliberately capped — the type-parameters proposal by Taylor, Griesemer, and the team — that gave the 80% (functions and containers over types) while structurally discouraging the 20% (no metaprogramming, no specialization, no operator-overloading-via-generics, no variance). The feature shipped because it was limited, not in spite of it.This is the lesson to carry: the disciplined choice was not "add the feature" or "refuse the feature." It was "refuse it until you can add a version small enough to do the needed job without enabling the harmful one." That is what earning complexity looks like as a multi-year engineering decision.
4 · The decision rule
Here is the rule the Go team itself published guidance for, compressed into an order of preference. Reach for the cheapest tool that works; escalate only on evidence.
In practice the sweet spots are narrow and well-known: generic container types (Stack[T], Set[T], a tree or a cache), and generic functions over collections where the element type flows through unchanged — Map, Filter, Keys, Max, SortFunc. The standard library's slices and maps packages (Go 1.21) are exactly this and nothing more — a strong signal of the intended scope. Outside those shapes, the burden of proof is on the generic: if you cannot name the concrete pain that copy-paste or an interface left unsolved, you have not earned it.
// One function, every element type, identity preserved:
// []T in, []U out, with the relationship checked at compile time.
func Map[T, U any](s []T, f func(T) U) []U {
r := make([]U, len(s))
for i, v := range s {
r[i] = f(v)
}
return r
}
names := Map([]int{1, 2, 3}, func(n int) string { return strconv.Itoa(n) })
// names is []string — no assertion, no any, no copy-paste per type.
An interface cannot type this (the U out / T in relationship is invisible to it); any would force an assertion on every element; copy-paste would mean one Map per (T, U) pair — combinatorial. This is the case generics were built for.Checkpoint exercise
any/Object/void* and casts on the way out. For each, run §4's rule out loud: Is there a shared behavior an interface could capture? Are there only two variants unlikely to grow? Or is this genuinely the "same type out as in, no shared behavior" gap? Then ask question 1 and question 5 of the series' five questions: what complexity does a generic add for the next reader, and what did the duplication or the runtime cast cost in the meantime? Decide honestly which is cheaper. Often the answer is "leave the two copies" — and recognizing that is the skill.Where this points next
Generics are the last language feature in the series, and they close the "programming in the large" movement with a feature you can only justify with evidence — "this duplication actually drifted," "this cast actually panicked." But evidence does not appear by intuition; it comes from tooling. Lesson 15 turns to the part of Go that is, uniquely, shipped with the language: go test and table-driven tests to prove correctness, testing.B benchmarks to measure the very claim from §2 that a generic is slower than its concrete twin, the race detector, and pprof. The decision rule you just learned is only as good as your ability to gather the numbers it depends on — and that is what tooling-as-language gives you.
[T C] carries a constraint C — itself an interface, now able to name a type set with | and ~ — that bounds both the types allowed and the operations the body may use. Go compiles them by GC shape (one copy per memory layout plus a dictionary), a middle bet between C++'s fast-but-bloated monomorphization and Java's small-but-boxed erasure — which means generics in Go buy reuse and safety, not speed, and often run slightly slower than the hand-written version. The thirteen-year refusal, then admission only of a deliberately limited design, is the bargain's clearest case study: complexity is earned by evidence that the absence costs more than a capped presence. The portable habit is the rule itself — interface, then copy-paste, then (only on evidence) a generic — reach for the most powerful abstraction last.Interview prompts
- What can a generic do that an interface cannot? (§1 — preserve the identity relationship "out-type equals in-type" and abstract over types that have no methods, like the built-ins; interfaces abstract over behavior only and hand back the interface type, not the concrete one.)
- Why is using
any+ a type assertion worse than a generic for aMaxfunction? (§1 — it switches off the type checker: correctness moves from compile time to a runtime assertion that can panic, and every caller must assert again on the way out.) - What does
~intmean in a constraint, and why does it matter? (§2 — "any type whose underlying type is int," so a defined type liketype Celsius intstill satisfies the constraint; without~onlyintitself would qualify.) - How does Go compile generics, and how does that differ from C++ and Java? (§2 — GC shape stomping: one instantiation per memory layout plus a dictionary for pointer-shaped types; C++ monomorphizes per concrete type (fast, bloated), Java type-erases to boxed
Object(small, boxing/casts). Go is the middle bet.) - Is a generic function faster than the hand-written concrete one? (§2 — usually slightly slower, due to dictionary indirection and inhibited inlining; generics buy reuse and type safety, not speed. Reaching for them for performance is the wrong reason.)
- Why did Go withhold generics for thirteen years, then ship them in 1.18? (§3 — the cost was the readability of the language at scale: generics are an attractive nuisance for type-level over-abstraction. They shipped only once evidence showed the absence cost more than a deliberately limited design — power capped to functions/containers, no metaprogramming.)
- Give the decision rule for generics vs. interfaces vs. copy-paste. (§4 — shared behavior → interface; only 2–3 stable variants → copy-paste; same-type-out-as-in or a container with no shared behavior → generic; reaching for speed → wrong reason. Cheapest tool first, escalate on evidence.)