Generics
Go 1.18 shipped type parameters in March 2022 — a decade-long debate that landed on a particular implementation choice. Not the full C++-style monomorphisation that produces a separate function per concrete type, and not Java-style type erasure either. Instead the compiler groups types by their GC shape, generates one body per shape, and passes a small dictionary at the call site to fill in what the body can't know.
What Go 1.18 shipped
Type parameters on functions and types, landed March 2022. You can write func Map[T, U any](xs []T, f func(T) U) []U or type Set[T comparable] map[T]struct{}
and the compiler handles the rest. The proposal had been kicking around in various
shapes since 2010; the final design is closer to Robert Griesemer and Ian Lance Taylor's
2020 type-sets proposal than to anything that came before.
C++ templates do full monomorphisation: every instantiation produces a separate compiled function, which is fast but bloats the binary and the build. Java uses type erasure: one body at runtime, types are checked at compile time but discarded. Go took a third path — generate one body per GC shape, share it across types with the same layout, and pass a dictionary at the call site. The result is closer to Rust's monomorphisation in spirit, but with aggressive deduplication.
The signature
The type parameter list sits in square brackets between the function name and the
ordinary parameter list. Each parameter has a constraint, which is either
any, comparable, or a named or inline interface that
describes the set of allowed types.
func Map[T, U any](xs []T, f func(T) U) []U {
out := make([]U, len(xs))
for i, x := range xs {
out[i] = f(x)
}
return out
}
// At the call site, the type list is usually inferred:
doubled := Map(nums, func(n int) int { return n * 2 })
// ^ no [int, int] needed — inferred from nums and fExplicit instantiation — Map[int, string](nums, itoa) — is allowed and
occasionally necessary when inference can't pin a parameter. In practice most
single-call-site uses get by without it.
GC-shape stenciling
Generating one function body per concrete type is the obvious implementation, and the one C++ uses. It's also the one that doubles binary size in libraries with heavy template use. The Go team didn't want that, and didn't want erasure either. The compromise is GC-shape stenciling.
Two types share a GC shape if they have the same size and the same pointer-bitmap
layout. All pointer types share one shape. All 8-byte integer types share another.
A slice []T always has the same shape regardless of T
(pointer + two ints). The compiler generates one function body per shape, not per
type — so Map[int, int], Map[int32, int32], and
Map[rune, rune] can all share machinery where their shapes match.
List[T] doesn't add a copy of every method to the binary for every
T a downstream user picks. The pointer shape covers all pointer
types; the int-sized shape covers all int-sized values; structs of unique sizes
still get unique shapes, but the long tail is shorter than full monomorphisation
would produce.Dictionaries
Shape-stenciled bodies have a gap: when the generic function calls a method on
T, or needs the size of T for a copy, the body can't know
those statically — they vary by concrete type within the shape. The compiler closes
the gap by passing a hidden first parameter: a dictionary.
A dictionary is a small read-only table the compiler builds per instantiation. It
holds type descriptors, sizes, and method pointers. When the body needs to call
t.String(), it loads the method pointer from the dictionary and makes
an indirect call. When it needs the size of T for
runtime.typedmemmove, it reads it from the dictionary.
// Roughly what the compiler does:
//
// func Stringify[T fmt.Stringer](xs []T) []string {
// out := make([]string, len(xs))
// for i, x := range xs { out[i] = x.String() }
// return out
// }
//
// Becomes (one shape body, parameterised by dict):
//
// func Stringify_shape(dict *dict, xs []T_shape) []string {
// ...
// out[i] = dict.stringMethod(&xs[i]) // indirect call
// ...
// }Cost: one extra parameter on the stack, one indirect call per method invocation.
Comparable to an interface-method call. For functions that don't call methods on
T — the pure Map/Filter shape — the
dictionary is small and rarely touched.
Type sets and constraints
A constraint is an interface, but with an extended grammar. Alongside the usual
method set, it can list concrete types as a union. ~int means "any
type whose underlying type is int" — so a custom type Celsius int
still satisfies it.
type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
func Sum[T Number](xs []T) T {
var s T
for _, x := range xs { s += x }
return s
}The golang.org/x/exp/constraints package ships ready-made sets:
constraints.Ordered covers every built-in type that supports
<, constraints.Integer covers all integer types, and
so on. Constraint interfaces aren't usable as ordinary interface types — they
only appear as type parameter bounds.
comparable, before and after 1.20
Pre-1.20, comparable was strict: a type parameter constrained by an
interface (say, any) couldn't be passed to a parameter constrained by
comparable, even though most concrete types satisfying that interface
would be comparable at runtime. The rule was sound but rejected a lot of useful code.
Go 1.20 loosened it. Now comparable is satisfied by any type that
supports == at compile time — including type parameters constrained by
interfaces whose method sets don't preclude comparability. The practical effect:
map[K]V with K comparable works in many generic contexts
it didn't before, and you can write Set[T comparable] without
constantly fighting the type checker.
comparable covers types where
== is defined, not where it's safe. Comparing two interface
values whose underlying type doesn't support == panics at runtime.
This is a long-standing Go rule, not a generics issue — but it shows up more often
with generic map keys.Performance characteristics
| Shape | Overhead vs hand-written |
|---|---|
Generic Map/Filter/Reduce | ~0–10%, sometimes faster (devirtualised) |
Generic data structure, no method calls on T | ~0%, often a clean win over interface{} |
Generic function that calls methods on T | ~one indirect call per use, similar to interface dispatch |
Typed Set[T] vs map[interface{}]struct{} | Often faster — no boxing on insert or lookup |
| Heavy generic struct field access | Not supported — see pitfalls |
Numbers depend on what the body does. Benchmark the specific shape you're replacing; the dictionary cost is small but non-zero, and inlining decisions around generic functions are still being tuned across releases.
Common pitfalls
- Reaching for generics where interfaces are clearer. If the
constraint is
anyor close to it, an interface is usually a better fit. Generics are for code that's the same across types; interfaces are for code that varies by behaviour. - Struct field access on a type parameter. You can call methods
on
Tvia a constraint, but you can't access fields. There's no way to writefunc GetID[T any](v T) int { return v.ID }. Define an interface with a method, or use reflection. - The comparable-with-non-pointer-equality trap.
comparablecovers==, not deep equality. Two slices with the same contents are not==and can't be map keys, generic or otherwise. - Generic recursion. Compile-time and shape-analysis cost grows
with recursive generic structures (
type Tree[T any] struct { left, right *Tree[T] }is fine; deeply nested generic chains are not). Profile your build if generics show up in tight inner libraries. - Constraint interfaces aren't runtime types. You can't write
var n Number = 3or do a type assertion toNumber— type sets only exist at the type-parameter level.
A production checklist
- Reach for generics for typed collections.
Set[T],OrderedMap[K, V],Stack[T], ring buffers, LRU caches. These are the cases where avoidinginterface{}boxing actually shows up in a profile. - Reach for generics for small utility functions.
Map,Filter,Reduce,Keys,Values. The standard library is adding these gradually (slices,maps,cmp). - Keep using interfaces for behaviour abstractions.
io.Reader,http.Handler,error— these are about what a value can do, not what shape it is. Generics don't replace them. - Keep type parameter lists short. Two or three is normal. Four or more is usually a sign the function is doing too many things, or that one of the types should be a field on a struct.
- Check binary size on heavily-templated packages. Shape
stenciling keeps growth modest, but a library that exposes dozens of generic
types instantiated by every caller can still add up.
go tool nmandgo build -ldflags=-wcomparisons are quick to run. - Benchmark before-and-after when replacing
interface{}code. Most of the time it's a small win. Occasionally it's a small loss (when the old code inlined well and the new one pays a dictionary call). Trust the measurement.
Further reading
- Proposal — Type parameters — Griesemer and Taylor's design doc; the canonical reference for what shipped and why.
- Keith Randall — Generics implementation — GopherCon talk on shape stenciling and dictionaries from the compiler engineer who built them.
- go.dev/blog — An introduction to generics — the official walk-through for users, with the rule-of-thumb on when to reach for type parameters.
- x/exp/constraints
—
Ordered,Integer,Float,Complex,Signed,Unsigned— the ready-made type sets. - /codex/languages/go/internals/interfaces/ — the abstraction generics is an alternative to. Same page covers when each is the right fit.