Module 1: Generics
Go Generics
Learning objectives
- Explain the core mental model behind Go Generics
- Apply Go Generics within Generics
- Identify important boundaries, trade-offs, and failure modes
- Produce concrete evidence from the practice exercise
Related: Go Interfaces | Go Slices and Arrays | Rust Generics and Monomorphization | Go Index
Why It Took Thirteen Years
Go shipped in 2009 without generics and added them in Go 1.18 (2022). That was not oversight — it was a series of rejected proposals, because every design conflicted with something Go valued more.
Before 1.18 you had three bad options:
1. interface{} + type assertions runtime cost, no type safety, ugly
func Max(a, b interface{}) interface{} { ... }
2. code generation (go:generate) a build step, generated files in the repo
//go:generate genny -in=list.go -out=int_list.go gen "T=int"
3. write it N times sort.Ints, sort.Strings, sort.Float64s
the standard library itself did this for a decadeThe design constraints the Go team refused to give up:
• compile speed must not regress → rules out full monomorphization
• no runtime type metadata explosion → rules out reified generics
• the syntax must be small → rules out C++-style templates
• existing code must keep working → no changes to interfacesThe result is GC shape stamping, a middle path between C++/Rust monomorphization and Java erasure, described below. It is less powerful than Rust's generics and considerably simpler.
Verification analogy: this is a SystemVerilog parameterized class — class fifo #(type T = int). The difference is the constraint: SV's parameter is unconstrained and fails at elaboration if the body uses a method T lacks; Go requires you to declare the constraint up front, so the error is at the generic definition rather than at instantiation.
Syntax
func Max[T cmp.Ordered](a, b T) T {
if a > b { return a }
return b
}
Max(3, 5) // T inferred as int
Max("a", "b") // T inferred as string
Max[float64](1, 2) // explicit instantiation func Name[T Constraint, U Constraint2](args) ReturnType
└───────────────────────────┘
TYPE PARAMETERS, in square brackets
the constraint is an INTERFACE. that is the whole design:
Go reused interfaces as the constraint language rather than
inventing a new one.Generic types:
type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] { return &Stack[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 idiom for "the zero value of T"
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
} IMPORTANT LIMITATION: METHODS CANNOT HAVE THEIR OWN TYPE PARAMETERS.
✗ func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U]
→ "method must have no type parameters"
✓ func Map[T, U any](s *Stack[T], f func(T) U) *Stack[U]
→ a free function instead
this is why the `slices` and `maps` packages are packages of FUNCTIONS
rather than methods. it is a real expressiveness gap versus Rust,
and it was accepted to keep method dispatch simple.Constraints
A constraint is an interface, extended with type sets.
// method constraint — an ordinary interface
type Stringer interface { String() string }
// TYPE SET constraint — new in 1.18, only usable as a constraint
type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
// combined
type Printable interface {
Number
String() string
} the ~ (tilde) means "any type whose UNDERLYING type is this"
int matches only int
~int matches int AND `type Cycles int`, `type Severity int`, ...
ALWAYS use ~ in a constraint unless you deliberately want to exclude
named types. forgetting it is the most common constraint bug:
type Cycles uint64
Sum([]Cycles{1,2,3}) ✗ if the constraint says `uint64`
✓ if it says `~uint64`The predeclared and standard constraints:
any // = interface{}, no constraint at all
comparable // supports == and != — required for map KEYS
cmp.Ordered // supports < <= > >= : integers, floats, strings
// golang.org/x/exp/constraints (not yet in std)
constraints.Integer
constraints.Float
constraints.Complex
constraints.Signed / Unsignedcomparable has a subtlety: it means "supports == and cannot panic doing so". An interface{} holding a slice panics on ==, so plain any does not satisfy comparable even though == compiles for it. Go 1.20 relaxed this so ordinary interface types do satisfy comparable, with the panic risk documented.
GC Shape Stamping: The Implementation
This is where Go differs from both Rust and Java, and it explains the performance characteristics.
RUST / C++ — full monomorphization
one machine-code copy per CONCRETE TYPE
Max[int], Max[int32], Max[float64], Max[string] → 4 functions
✓ zero cost, fully inlinable, vectorizable
✗ compile time and binary size grow with instantiations
JAVA — erasure
ONE copy, everything is Object, values are boxed
✗ every access boxes/casts; List<Integer> is a list of pointers
GO — GC SHAPE STAMPING
one copy per GC SHAPE. types with the same size and pointer layout
SHARE an implementation, receiving a hidden "dictionary" argument
that carries the type-specific details.
int, int64, uint64, float64, Cycles → same shape (8 bytes, no pointers)
→ ONE compiled function
*T, map, chan, func → same shape (one pointer)
string → its own shape (ptr + len)
[]T → its own shape (ptr + len + cap)
struct{a,b int} → its own shape
✓ modest compile time and binary size
✗ the dictionary is an indirection: generic code is often SLOWER than
a hand-written specialization, and sometimes slower than an
interface-based versionThe practical consequence is important and frequently surprising: Go generics are not guaranteed to be faster than interface{}. They give you type safety and better ergonomics; they do not give you Rust's zero-cost promise.
// this may NOT be faster than a sort.Interface implementation
func SortBy[T any](s []T, less func(a, b T) bool)Benchmark before assuming a generic rewrite improves performance. It usually improves correctness and readability, which is the better reason to do it (Go Testing and Benchmarking).
The Standard Library Payoff
Generics made three genuinely useful packages possible:
import ("slices"; "maps"; "cmp")
// slices — Go 1.21
slices.Contains(s, v) slices.Index(s, v)
slices.Sort(s) slices.SortFunc(s, cmp)
slices.SortStableFunc(s, cmp) slices.BinarySearch(s, v)
slices.Clone(s) slices.Equal(a, b)
slices.Max(s) / slices.Min(s) slices.Reverse(s)
slices.Insert(s, i, vals...) slices.Delete(s, i, j)
slices.Compact(s) slices.Concat(a, b)
slices.Chunk(s, n) // 1.23, returns an iterator
// maps — Go 1.21/1.23
maps.Keys(m) // 1.23: returns an ITERATOR
maps.Values(m)
maps.Clone(m) maps.Equal(a, b)
slices.Sorted(maps.Keys(m)) // ← the deterministic-iteration idiom
// cmp
cmp.Compare(a, b) // -1, 0, +1
cmp.Or(a, b, c) // first non-zero valueThe old way versus the new way:
// before 1.21
sort.Slice(beats, func(i, j int) bool { return beats[i].Addr < beats[j].Addr })
// after
slices.SortFunc(beats, func(a, b Beat) int { return cmp.Compare(a.Addr, b.Addr) })The generic version takes values rather than indices, which eliminates the entire class of index-confusion bugs in comparator functions.
Examples
Example 1: A generic bounded FIFO
package fifo
import "fmt"
type Fifo[T any] struct {
items []T
depth int
}
func New[T any](depth int) *Fifo[T] {
if depth <= 0 { panic("fifo: depth must be positive") }
return &Fifo[T]{items: make([]T, 0, depth), depth: depth}
}
func (f *Fifo[T]) Push(v T) bool {
if len(f.items) >= f.depth { return false } // full: backpressure
f.items = append(f.items, v)
return true
}
func (f *Fifo[T]) Pop() (T, bool) {
var zero T // the zero-value idiom
if len(f.items) == 0 { return zero, false }
v := f.items[0]
f.items[0] = zero // let the GC collect it
f.items = f.items[1:]
return v, true
}
func (f *Fifo[T]) Len() int { return len(f.items) }
func (f *Fifo[T]) Full() bool { return len(f.items) >= f.depth }
// a free function, because methods cannot have their own type parameters
func Map[T, U any](f *Fifo[T], fn func(T) U) *Fifo[U] {
out := New[U](f.depth)
for _, v := range f.items { out.Push(fn(v)) }
return out
}
// ── usage ─────────────────────────────────────────────────────────
type Beat struct {
Addr uint64
Data uint32
}
func main() {
f := New[Beat](4)
for i := 0; i < 6; i++ {
ok := f.Push(Beat{Addr: uint64(i) * 64, Data: uint32(i)})
if !ok { fmt.Printf("dropped beat %d: FIFO full\n", i) }
}
addrs := Map(f, func(b Beat) uint64 { return b.Addr })
for {
a, ok := addrs.Pop()
if !ok { break }
fmt.Printf("%#x ", a)
}
fmt.Println()
}dropped beat 4: FIFO full
dropped beat 5: FIFO full
0x0 0x40 0x80 0xc0Three details worth noting. var zero T is the only way to produce a T's zero value in generic code. f.items[0] = zero before re-slicing matters when T contains pointers — without it the popped element stays reachable through the backing array and is never collected (Go Slices and Arrays). And Map is a free function because of the no-type-parameters-on- methods rule.
Compare with fifo #(.T(axi_beat), .DEPTH(4)) in SystemVerilog (Parameterized Classes): same shape, but Go's [T any] constraint means the compiler verifies the body works for every T at the definition, not at each instantiation.
Example 2: Constraints doing real work
package main
import (
"cmp"
"fmt"
"maps"
"slices"
)
// ~ is essential: this must accept `type Cycles uint64`
type Numeric interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
func Sum[T Numeric](vals []T) T {
var total T
for _, v := range vals { total += v }
return total
}
func Mean[T Numeric](vals []T) float64 {
if len(vals) == 0 { return 0 }
return float64(Sum(vals)) / float64(len(vals))
}
// two type parameters: the element type and the key type
func GroupBy[T any, K comparable](items []T, key func(T) K) map[K][]T {
out := make(map[K][]T)
for _, it := range items { out[key(it)] = append(out[key(it)], it) }
return out
}
func MaxBy[T any, K cmp.Ordered](items []T, key func(T) K) (T, bool) {
var zero T
if len(items) == 0 { return zero, false }
best := items[0]
for _, it := range items[1:] {
if key(it) > key(best) { best = it }
}
return best, true
}
// ── domain types ──────────────────────────────────────────────────
type Cycles uint64 // a NAMED type — needs ~
type Result struct {
Test string
Seed uint64
Cycles Cycles
}
func main() {
cyc := []Cycles{1200, 800, 3400, 1100}
fmt.Printf("sum=%d mean=%.1f\n", Sum(cyc), Mean(cyc)) // ✓ works because of ~
results := []Result{
{"axi_smoke", 1, 1200}, {"axi_smoke", 2, 800},
{"pcie_link", 1, 3400}, {"pcie_link", 2, 1100},
}
byTest := GroupBy(results, func(r Result) string { return r.Test })
for _, test := range slices.Sorted(maps.Keys(byTest)) {
rs := byTest[test]
worst, _ := MaxBy(rs, func(r Result) Cycles { return r.Cycles })
fmt.Printf("%-12s %d runs, worst seed %d at %d cycles\n",
test, len(rs), worst.Seed, worst.Cycles)
}
}sum=6500 mean=1625.0
axi_smoke 2 runs, worst seed 1 at 1200 cycles
pcie_link 2 runs, worst seed 1 at 3400 cyclesGroupBy and MaxBy are the two generic helpers most worth having in a codebase — they were impossible to write once before 1.18 and had to be re-written per type or done with interface{} and reflection.
K comparable on GroupBy is required because K is used as a map key (Go Maps). K cmp.Ordered on MaxBy is required because the body uses >. The constraints are not decoration; each one is exactly what the body needs.
Example 3: When generics are the wrong tool
// ✗ over-generic: T is unconstrained and unused in any interesting way
func Process[T any](items []T, fn func(T)) {
for _, i := range items { fn(i) }
}
// this is just a for loop. write the loop.
// ✗ generics where an interface is clearer and equally fast
func Write[W io.Writer](w W, data []byte) error { // pointless type parameter
_, err := w.Write(data)
return err
}
func Write(w io.Writer, data []byte) error { ... } // ✓ simpler, same behaviour
// ✓ generics earning their keep: the return type DEPENDS on the input type
func Keys[K comparable, V any](m map[K]V) []K {
out := make([]K, 0, len(m))
for k := range m { out = append(out, k) }
return out
}
// impossible with interfaces: []any loses the type WHEN TO REACH FOR GENERICS
✓ containers: Stack[T], Fifo[T], Set[T], Cache[K,V]
✓ algorithms over slices/maps where the ELEMENT TYPE is preserved
✓ you are writing the same function for the third type
✓ the return type depends on the parameter type
✗ the operation is fully described by an interface → use the interface
✗ you have one instantiation → write it concretely
✗ you are reaching for it to look sophisticated
✗ you expect it to be faster than an interface → benchmark firstThe Go team's own guidance is unusually blunt: "write code, not types. Start concrete, and generalize when you have three instances." That is stricter advice than most languages give, and it is consistent with Go's whole design philosophy (Go What and Why).
What Trips People Up
- Forgetting
~in a constraint.uint64excludestype Cycles uint64. Almost always - Methods with type parameters. Not allowed. Use a free function.
var zero T. The only way to get T's zero value. There is noT{}ornil.- Expecting monomorphization performance. GC shape stamping adds a dictionary
comparablevsany. Map keys needcomparable;anydoes not satisfy it (pre-1.20).- Type inference failing. It works on arguments, not return types. If
Tappears only in - Constraints as regular interfaces. A type-set constraint (
~int | ~string) cannot be - Over-generalizing. Three concrete functions are often clearer than one generic one. Go
- Generic methods on a generic type needing a new parameter. The single most requested
you want ~uint64.
indirection. Generic code can be slower than a hand-written specialization.
the return, you must instantiate explicitly.
used as a variable type, only as a constraint.
culture strongly favours the concrete version.
missing feature; there is no workaround except a free function.
Related
- Go Interfaces — the constraint language, and the alternative to generics
- Go Slices and Arrays — the
slicespackage - Go Maps — the
mapspackage andcomparable - Go Types and Zero Values — named types and why
~matters - Go Testing and Benchmarking — verifying a generic rewrite is not slower
- Rust Generics and Monomorphization — full specialization and true zero cost
- Rust Traits — trait bounds as the constraint equivalent
- Python More Type Hints —
TypeVarandGenericfor contrast - Go Index — full topic map
Practice lab
Build the smallest runnable Go program that demonstrates Go Generics. Add a table-driven test, run the relevant Go diagnostics, inject one realistic failure, and explain the evidence used to correct it. Add an operational constraint such as concurrency, recovery, security, latency, or cost, and defend the resulting design trade-off.
Review questions
- What problem does Go Generics solve, and what assumptions does it rely on?
- Which boundary or failure case is easiest to miss, and how would you expose it?
- What alternative design would you consider, and what trade-off would change the decision?
- What artifact, trace, test, or metric proves that your implementation is correct?
Completion evidence
- A working artifact, annotated trace, or reproducible experiment
- At least one normal case and one deliberately failing or boundary case
- A concise explanation of the design choice and its trade-offs
- Saved output showing how correctness was evaluated