Go had resisted generics for years. The arguments against were practical: generics complicate the language, they interact badly with Go’s interface system, and most cases where you want generics can be handled with interface composition or code generation. The arguments weren’t wrong.

But the proposal that eventually shipped in Go 1.18 (2022) addressed a real gap — a gap that was producing either duplicated code or interface{} with runtime type assertions everywhere. Here’s what the proposal was solving.

What interface{} Actually Costs

The standard workaround for generic containers before generics: interface{} (or any in Go 1.18).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// A stack that works with any type:
type Stack struct {
    items []interface{}
}

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

func (s *Stack) Pop() interface{} {
    if len(s.items) == 0 {
        return nil
    }
    item := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return item
}

// Usage:
stack := &Stack{}
stack.Push(42)
val := stack.Pop().(int)  // runtime type assertion — panics if wrong type

Problems:

  • No compile-time safety: stack.Pop().(string) compiles, panics at runtime
  • Boxing overhead: every int, float64, struct pushed onto the stack gets boxed into an interface{} — heap allocation, extra indirection
  • Unclear contract: the type signature says interface{} — what does this stack actually hold?

The interface{} approach is fine for truly polymorphic code (a function that works with any value). It’s the wrong tool for containers and algorithms that are type-safe in every language that supports generics.

What Code Generation Costs

The alternative: code generation. Tools like go generate with templates can produce type-specific implementations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
//go:generate stringer -type=Direction

// Or a generic container via gen:
//go:generate gen generate

// Produces:
type IntStack struct { items []int }
func (s *IntStack) Push(item int) { ... }
func (s *IntStack) Pop() int { ... }

type StringStack struct { items []string }
func (s *StringStack) Push(item string) { ... }
func (s *StringStack) Pop() string { ... }

Code generation works but:

  • Generated code is checked in or regenerated on every build (friction)
  • The generator is another tool to maintain
  • New types require re-running the generator
  • Reading the generated code obscures the actual logic

The 2020 Proposal

The generics proposal by Ian Lance Taylor and Robert Griesemer introduced type parameters — a way to parameterise functions and types over types:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Type-safe stack with generics:
type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(item T) {
    s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() (T, bool) {
    var zero T
    if len(s.items) == 0 {
        return zero, false
    }
    item := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return item, true
}

// Usage:
stack := &Stack[int]{}
stack.Push(42)
val, ok := stack.Pop()  // val is int, no type assertion

The type parameter [T any] means “this type works for any type T.” The compiler generates appropriate code (or uses a single implementation with boxing depending on the calling context — an implementation detail).

Type Constraints

The any constraint means “any type at all.” More useful are constraints that restrict what types are allowed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Only numeric types:
type Number interface {
    ~int | ~int32 | ~int64 | ~float32 | ~float64
}

func Sum[T Number](items []T) T {
    var total T
    for _, item := range items {
        total += item
    }
    return total
}

Sum([]int{1, 2, 3})        // works
Sum([]float64{1.1, 2.2})   // works
Sum([]string{"a", "b"})    // compile error: string does not satisfy Number

Constraints are interfaces with a new ~T | ~U syntax for union types. The ~ means “underlying type” — ~int matches any type whose underlying type is int, including type UserID int.

What It Actually Enables

The headline use cases:

Generic data structures: type-safe maps, queues, trees, heaps without code generation or interface{}.

Generic utility functions: Map, Filter, Reduce that work for any type, with compile-time safety:

1
2
3
4
5
6
7
8
9
func Map[In, Out any](items []In, f func(In) Out) []Out {
    result := make([]Out, len(items))
    for i, item := range items {
        result[i] = f(item)
    }
    return result
}

prices := Map(orders, func(o Order) float64 { return o.Price })

Typed event buses, channels, results: instead of chan interface{}, use chan T with a type parameter.

What It Doesn’t Solve

Generics in Go are not Java generics. They don’t support method-level type parameters on types (only function-level), which limits certain patterns. They don’t support variance (no covariance/contravariance). The constraint system is simpler than Haskell typeclasses or Rust traits.

The Go team was deliberate about this. The goal was to handle the most common cases (containers, algorithms) cleanly, not to add the full expressiveness of a type-theoretical generics system.

For the cases generics don’t cover, the existing approaches (interface composition, any, code generation) remain appropriate. Generics are not a replacement for everything — they’re the right tool for a specific category of problems that was previously solved badly.