all_lessons/Go/14 · Genericslesson 15 / 18

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.

The thesis, here
Every other lesson has shown Go leaving a feature out and asking you to feel what the absence bought. This one shows the other side of the same accounting: a feature held back for over a decade, then admitted — and exactly why the wait was the disciplined choice, not the cowardly one. Generics are the purest demonstration that "complexity must be earned" is a real, applied budget and not a slogan. The transferable skill is the rule itself: reach for the most powerful abstraction last, only when the cheaper tools have measurably failed.
Linear position
Prerequisite: Lesson 13 — the standard library taught you that a small interface (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.
The plan
Four moves. (1) The concrete pain that interfaces and 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."

Copy-paste (A)
Reader understands each instance instantly; the type system is fully engaged. Cost: N copies drift; a fix in one is a bug in the others. Fine for 2 copies, a liability at 6.
any + assertion (B)
One implementation, any type. Cost: the type system is switched off — correctness becomes a runtime panic, and every caller asserts on the way out. The worst of both.
Interface (C)
The right tool for a behavior, zero extra machinery. Cannot express "out-type equals in-type," and cannot abstract over types that have no methods (the built-ins). This is the exact gap.

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.

StrategyWhat it generatesCost it pays
C++ templatesOne full copy per concrete type, monomorphizedFastest code; large binaries & long compiles (code bloat) — the C++ track's lesson
Java genericsNothing — type-erased to Object, boxed at runtimeTiny binary; boxing allocations & runtime casts; List<int> can't even exist
Go (GC shape)One copy per memory layout + a dictionary for the restA 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.

What actually changed the math
The "no" flipped to "yes" for two reasons, both honest accounting. (1) The cost of absence had compounded: after a decade the community had accumulated real evidence — duplicated container types, the 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.

ask, in this order: 1. Is the operation a BEHAVIOR several types share? (Read, Write, String, Less...) --> use an INTERFACE. [L05/L13] 2. Are there only 2 (maybe 3) variants, unlikely to grow? --> COPY-PASTE. The reader understands it instantly; drift risk is small. "A little copying is better than a little dependency." 3. Do you need the SAME type out as in, across many types, OR a container holding an arbitrary type, with NO shared behavior to lean on? --> this is the gap interfaces can't reach. use a GENERIC. 4. Reaching for generics for SPEED? --> wrong reason (see §2: usually slower). Stop.

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.

A concrete "yes": Map over a slice
// 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

Try it
Find a place in your own code — any language — where you have either (a) two or more near-identical functions differing only by type, or (b) a container that stores 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.

Takeaway
Generics let you write a function or container once, with full compile-time type safety, for the narrow case interfaces structurally cannot reach: when there is no shared behavior to abstract over, only the identity relationship "the type that comes out is the type that went in." A type parameter [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