The Thirteen-Year Argument: How Go Finally Got Generics

The Thirteen-Year Argument: How Go Finally Got Generics

Go spent thirteen years refusing to add generics. The story of that refusal, the designs it rejected, and why waiting turned out to be the right call.

By Omar Flores
Table of Contents

The first production panic I ever debugged in Go came from a type assertion.

Nothing exotic. A service that pushed jobs into a queue and pulled them out on the other side. The queue was a struct with an []interface{} inside, because this was 2018 and that’s what you did. On the producing side, someone had pushed an int32. On the consuming side, someone else — months earlier, in a different pull request — had written job.(int). Both lines were correct. Both lines compiled. The compiler, which had vouched for every other character in that file, had nothing to say about this one.

interface conversion: interface {} is int32, not int

I remember staring at that panic thinking the wrong thing. I blamed the developer who wrote the assertion. It took me longer than I’d like to admit to see the actual culprit: the language had handed us a container that forgot types on purpose, and then made us prove every value’s identity by hand, at runtime, in production.

Go had an answer to that complaint. It was thirteen years old and it was “no.”

The comment that started the longest argument in Go’s history

Go went open source on November 10, 2009. According to Ian Lance Taylor — who by then had already been sketching generics designs for the language for a while — the first public comment asking for generics arrived less than 24 hours after the release.

Twenty-four hours. The language hadn’t finished its first day and someone had already filed the request that would outlive every other issue in the tracker.

The same commenter also asked for exceptions. Go added exceptions — sort of — as panic and recover, early in 2010. Fast answer, done, moved on.

The generics request got a twelve-year research program.

That asymmetry tells you everything about the situation. The team wasn’t oblivious. They weren’t slow. Generics topped the official Go user surveys in 2016 and 2017, sitting right next to package management, the other famous hole in the language. Package management became modules. Generics kept winning the popularity contest and kept losing the implementation one.

So why the delay?

Three speeds, pick two

In 2009, Russ Cox gave the problem a name that still holds up: the generic dilemma. Any generics implementation, he argued, seems forced to pick two of three:

  • fast programmers — writing the generic code is easy
  • fast compilers — compilation stays quick
  • fast execution — the generated code runs at native speed

That sounds abstract until you see that every major ecosystem had already picked its two and paid for it, visibly, for decades.

C++ picked fast programmers and fast execution. Templates compile to specialized machine code, as fast as it gets. The bill goes to the compiler, and templates aren’t just a type feature — they’re a programming language that runs inside the compiler. Large C++ codebases famously spent minutes compiling. Rob Pike’s story about sitting at Google waiting for a massive C++ build to finish is the founding anecdote of Go itself. Generics weren’t an abstract idea to the people designing Go. They had watched what templates did to their compile times from the inside.

Java picked the other corner: fast compiler, fast programmers. Type erasure keeps the compiler simple — List<String> and List<Integer are the same thing once the program runs, and every boxed value pays at runtime. The type safety exists in the compiler’s head and evaporates before the JVM executes a single instruction.

// Java: the compiler checks, the runtime forgets.
// List<int> doesn't exist. It's List<Integer>, with boxing.
List<Integer> xs = new ArrayList<>();

Go refused to pick either poison. For its first decade it lived in the third corner: fast compiler, fast execution, and writing reusable code across types was your problem. Duplicated functions. interface{}. Code generation. The team’s judgment, for years, was that a little duplication and a little unsafe abstraction were cheaper than either a slow compiler or a slow runtime.

Here’s the part worth sitting with: they were not obviously wrong. That’s what makes the story interesting. If the trade-off had been clean — say, generics cost nothing — the delay would just be neglect. But every design they tried genuinely took something away. The dilemma was real. The question was which corner Go could eventually leave, and only after years of evidence did the answer emerge.

What we actually did for thirteen years

To understand why the pressure kept building, you have to feel what the workarounds cost in practice. I’ve maintained Go codebases from that era, and I can tell you the pain wasn’t theoretical. It had a shape. Three shapes, actually.

The first shape was the interface{} stack, like the one from my panic story:

type Stack struct {
	items []interface{}
}

func (s *Stack) Push(v interface{}) {
	s.items = append(s.items, v)
}

func (s *Stack) Pop() interface{} {
	n := len(s.items)
	v := s.items[n-1]
	s.items = s.items[:n-1]
	return v
}

This compiled in milliseconds and ran at full speed, and both of those facts were exactly the problem. It made the wrong design frictionless. The moment a value crossed through interface{}, the compiler stopped vouching for it, and the burden of proof shifted to you:

stack.Push(42)
stack.Push("hello") // the compiler says nothing

value := stack.Pop().(int) // panics at runtime

Go’s entire pitch to large engineering teams was static type safety from a small type system. The interface{} workaround surrendered that safety precisely where teams needed abstraction most — containers, algorithms, shared libraries, the code everyone depends on. Every .(T) assertion was a small bet the compiler wasn’t allowed to check. Multiply that across a few hundred call sites and production panics stop feeling like bugs and start feeling like weather.

The second shape was code generation. go generate templates stamping out one typed copy of a data structure per type. It worked, and some large Go shops built their whole abstraction strategy on it. I inherited a repo once where a generator produced tens of thousands of lines of typed queue, set, and map code from a single template. It compiled fast. It ran fast. And it was hostile: stack traces pointed into generated files nobody had written, diffs were unreviewable, and every onboarding conversation had to include the sentence “don’t edit that, it’s generated.” We had outsourced the type system to a template engine and paid for it in readability, forever.

The third shape was the standard library’s own compromise. sort.Slice accepts a closure and does its work through reflection under the hood. Flexible, sure. Also slower than a hand-written sort, and completely opaque at the call site — nothing about users[i].Name < users[j].Name tells you what machinery is grinding underneath.

That’s the detail I keep coming back to. Go’s own standard library was shipping visible seams. The people who built the language had to build their sort around the missing feature. In 2016 and 2017, the surveys said the community had noticed too. The pressure wasn’t a Twitter argument. It was accumulating in every codebase in the ecosystem, one type assertion at a time.

Contracts: the design that had to die

By 2018 the team started designing in the open, and the first serious attempt was the one that looked most like what you’d expect a generics system to look like: contracts.

The idea was seductive. A contract described what a type parameter could do, written almost like pseudocode:

contract Addable(T) {
	T + T
}

If the contract body compiled for some type, that type satisfied the contract. Write the operations you need; capability follows. The 2018 GopherCon draft and its July 2019 refinement were built on this, and for a while it looked like the destination.

The community reaction killed it, and having watched that debate, I think the community was right. Contracts introduced a second, parallel mechanism that looked like functions but wasn’t. Every edge case spawned a new rule — can a contract mention types with no methods? Can it relate two type parameters to each other? What does the contract body mean, exactly, when nothing calls it? Each answer was reasonable in isolation. Together they were a second language wearing Go’s clothes.

The team’s own admission came in June 2020, and it’s one of my favorite sentences in the whole saga: the difference between contracts and interface types was confusing, so they would eliminate the difference. Contracts dropped. Years of design work, publicly discarded, because the feedback was consistent and the feedback was right.

There’s an unusual coda. Around that time the team asked researchers for help, and Philip Wadler and collaborators produced Featherweight Go — a formal model proving a simplified version of Go could support generics cleanly. The theory said the destination was reachable. What remained was the harder problem: finding a road there that still felt like Go.

The answer had been in the language since 2009

The final design — the Type Parameters Proposal, by Ian Lance Taylor and Robert Griesemer, accepted in August 2021 — made a move so quiet that I’d bet most developers using generics today have never noticed it as a design decision at all.

Start from what an interface always was. In pre-generics Go, an interface answers one question: which methods does this type have? io.Reader means “anything with a Read method.” That’s a set of types, too — just a set you never thought of as one, because it was defined by behavior.

The proposal generalized the question. An interface used as a constraint answers: which types are allowed here? Same construct, wider reading. That is a type set.

type Number interface {
	~int | ~int64 | ~float64
}

Don’t read that as “types with certain methods.” Read it as a set literal: every type whose underlying type is int, or int64, or float64. The | is a union. And the ~ — that little tilde exists because of a detail of Go that would have broken everything without it.

type UserID int

UserID is a distinct type. Its underlying type is int. Go programmers declare types like this constantly — it’s half of what “strongly typed” means in practice — and a constraint of plain int would have excluded all of them. Generics would have worked only for the built-in types, and every domain type would need an awkward cast on its way through any generic function. With ~int, anything built on int fits. The designers looked at how people actually write Go and made the constraint system respect it.

Now step back and look at what the final design contains. No new keyword. No second language. No template engine inside the compiler. The mechanism Go had carried since day one — small interfaces — turned out to be elastic enough to hold constraints too. Thirteen years of refusal forced the team to keep searching until the feature could be added without adding a concept. Only a new use for an existing one.

I don’t think that’s a coincidence. I think it’s the whole lesson.

What landed in 2022, and what we did with it

Go 1.18 shipped in March 2022. The syntax took an afternoon to learn, and the first thing most people wrote was the same example the team used in the release notes:

func Max[T cmp.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

// T is inferred at each call site. No explicit type arguments needed.
Max(10, 20)         // int
Max(3.14, 2.71)     // float64
Max("alice", "bob") // string

There’s a trap hiding in that example, and it’s my favorite illustration of how the constraint system thinks. The obvious first instinct is func Max[T comparable](a, b T) T — and it compiles right up until you write the comparison, because comparable promises only that values can be checked for equality. Not ordering. The compiler rejects >, and it should. You asked for “any type that supports ==,” got it, and then tried to do something your constraint never promised. That’s not a generics quirk. That’s the contract doing its job — a runtime panic, relocated to compile time, where it costs nothing.

Note what did not change. The compiler stayed fast. Inference handles ordinary calls, so generic code reads like the concrete code it replaced. No Java-style runtime boxing, no C++-style metaprogramming — the implementation takes a deliberate middle path through the same dilemma Cox named in 2009, and you can spend a weekend reading about GC shape stenciling if that rabbit hole calls to you.

The real payoff arrived with Go 1.21, in August 2023: slices, maps, and cmp in the standard library. Side by side, the change is easier to feel than to explain:

// Before: swapping handled by reflection, hidden behind a closure.
sort.Slice(users, func(i, j int) bool {
	return users[i].Name < users[j].Name
})

// After: fully typed, the compiler verifies the comparison.
slices.SortFunc(users, func(a, b User) int {
	return strings.Compare(a.Name, b.Name)
})

Generics stopped being a language feature you could admire and became a library you use without thinking. In Go, that’s the only place a feature is allowed to live for long.

But I’d be lying if I said the landing was graceful everywhere. The week 1.18 came out, I watched codebases grow a [T any] on functions that had exactly one possible type. Abstractions with a single implementation. Generic interfaces where a two-method concrete interface would have been clearer. The Go team had published guidance and it was conservative — reach for generics when the alternative is duplicated code that differs only in types, or containers that must work across many types; keep them away from code where a plain interface or a concrete type does the job. A lot of teams read the syntax and skipped the guidance.

The test I’ve settled on after a few years of living with this: if removing the type parameter means copy-pasting the function once per type and nothing else changes, generics earn their place. If removing it means writing an interface instead, write the interface. Type parameters charge every reader an indirection tax. Collect it only when the duplication or the lost type safety costs more.

What thirteen years of no buys you

When people tell this story, the temptation is to frame it as Go being stubborn and then finally caving. I think that gets it exactly backwards.

The team said no because every design they could see cost something they had promised not to spend — compile speed, runtime speed, or the simplicity of the language itself. They said no through four shelved internal designs, a decade of surveys, and a public detour through contracts that they ultimately tore down themselves. And when the design finally arrived, it wasn’t a concession. It was the discovery that the feature could fit inside a concept the language already had.

Waiting didn’t just delay the feature. It’s the reason the feature is good.

Most of us will never design a programming language. But every team I’ve worked on has a version of this argument running — a framework everyone wants to adopt before the abstraction is understood, a rearchitecture pending until the requirements stop moving, a feature request with thirteen years of momentum behind it. The Go story doesn’t say “never say yes.” It says that the cost of an abstraction is real even when the syntax is new and fun, and that a no, held long enough and revisited honestly enough, can be the most valuable thing a design process produces.

The features a language refuses the longest are often the ones it ends up building best.

timeline
    title Thirteen years of the generics argument
    2009 : Go released (Nov 10) : First generics comment within 24 hours : Russ Cox names the generic dilemma
    2010-2016 : interface{}, codegen, duplicated code : Four internal designs explored, all shelved
    2016-2017 : Top requested feature in official surveys
    2018-2019 : GopherCon contracts draft : Contracts dropped as too complicated
    2020 : Interface types replace contracts : Featherweight Go proves the theory
    2021 : Type Parameters Proposal accepted
    2022 : Go 1.18 ships generics
    2023 : Go 1.21 ships slices, maps, cmp