08 / 10
Internals / 08

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 f

Explicit 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.

Why this matters. A library that exposes a generic 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.

The runtime trap. 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

ShapeOverhead 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 accessNot 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 any or 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 T via a constraint, but you can't access fields. There's no way to write func 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. comparable covers ==, 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 = 3 or do a type assertion to Number — 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 avoiding interface{} 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 nm and go build -ldflags=-w comparisons 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

Found this useful?