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 decade

The 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 interfaces

The 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 / Unsigned

comparable 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 version

The 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 value

The 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 0xc0

Three 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 cycles

GroupBy 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 first

The 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. uint64 excludes type Cycles uint64. Almost always
  • you want ~uint64.

  • Methods with type parameters. Not allowed. Use a free function.
  • var zero T. The only way to get T's zero value. There is no T{} or nil.
  • Expecting monomorphization performance. GC shape stamping adds a dictionary
  • indirection. Generic code can be slower than a hand-written specialization.

  • comparable vs any. Map keys need comparable; any does not satisfy it (pre-1.20).
  • Type inference failing. It works on arguments, not return types. If T appears only in
  • the return, you must instantiate explicitly.

  • Constraints as regular interfaces. A type-set constraint (~int | ~string) cannot be
  • used as a variable type, only as a constraint.

  • Over-generalizing. Three concrete functions are often clearer than one generic one. Go
  • culture strongly favours the concrete version.

  • Generic methods on a generic type needing a new parameter. The single most requested
  • 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 slices package
  • Go Maps — the maps package and comparable
  • 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 — TypeVar and Generic for 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

  1. What problem does Go Generics solve, and what assumptions does it rely on?
  2. Which boundary or failure case is easiest to miss, and how would you expose it?
  3. What alternative design would you consider, and what trade-off would change the decision?
  4. 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