Chapter 8

Generics: From interface{} to Type Parameters

Generics: From interface{} to Type Parameters

On March 15, 2022, Go 1.18 was officially released, bringing the largest syntax change since Go's inception โ€” Type Parameters, commonly known as generics. This feature had been discussed since Go was open-sourced in 2009, went through 12 years of design iteration, multiple rejected proposals, and was finally accepted based on the design proposed by Ian Lance Taylor and Robert Griesemer in 2019.

Why did a seemingly "fundamental" feature require such a long wait? Because the Go team's design principle for generics was: it must not compromise Go's simplicity. They would rather have no generics than introduce a generic system that complicates the language. The final design indeed increased expressiveness while maintaining Go's simplicity โ€” at the cost of being more restrictive than Rust's or C++'s generics.

This chapter starts from basic generic syntax, dives into compiler implementation strategies, reviews the historical evolution of proposals, and concludes with correct usage scenarios and known limitations.

Level 1: What You Need to Know

Go Before Generics

Before Go 1.18, handling "multi-type generic logic" primarily used three approaches:

Approach 1: Code Duplication

func MaxInt(a, b int) int {
    if a > b {
        return a
    }
    return b
}

func MaxFloat64(a, b float64) float64 {
    if a > b {
        return a
    }
    return b
}

func MaxString(a, b string) string {
    if a > b {
        return a
    }
    return b
}

One function per type, identical logic. Maintenance cost grows linearly with the number of types.

Approach 2: interface{} + Type Assertions

func Max(a, b interface{}) interface{} {
    switch a := a.(type) {
    case int:
        if a > b.(int) {
            return a
        }
        return b
    case float64:
        if a > b.(float64) {
            return a
        }
        return b
    default:
        panic("unsupported type")
    }
}

Problems:

Approach 3: Code Generation

Using go generate and template tools (like genny, gen) to auto-generate type-specific code. But this increases build complexity, and generated code is hard to debug.

Generic Functions

With Go 1.18's type parameters, the Max function needs to be written only once:

func Max[T constraints.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

// Usage
result := Max[int](3, 5)         // explicitly specify type argument
result2 := Max(3.14, 2.71)       // compiler infers float64
result3 := Max("hello", "world") // compiler infers string

Syntax breakdown:

Multiple type parameters:

func Map[T any, R any](slice []T, fn func(T) R) []R {
    result := make([]R, len(slice))
    for i, v := range slice {
        result[i] = fn(v)
    }
    return result
}

// Usage
strings := Map([]int{1, 2, 3}, func(n int) string {
    return fmt.Sprintf("%d", n)
})
// strings = ["1", "2", "3"]

Generic Types

Not just functions โ€” type definitions can also have type parameters:

// Generic stack
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) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    item := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return item, true
}

func (s *Stack[T]) Len() int {
    return len(s.items)
}

// Usage
intStack := &Stack[int]{}
intStack.Push(1)
intStack.Push(2)
val, ok := intStack.Pop() // val = 2, ok = true

strStack := &Stack[string]{}
strStack.Push("hello")

Generic Set type:

type Set[T comparable] struct {
    m map[T]struct{}
}

func NewSet[T comparable]() *Set[T] {
    return &Set[T]{m: make(map[T]struct{})}
}

func (s *Set[T]) Add(item T) {
    s.m[item] = struct{}{}
}

func (s *Set[T]) Contains(item T) bool {
    _, ok := s.m[item]
    return ok
}

func (s *Set[T]) Remove(item T) {
    delete(s.m, item)
}

Type Constraints

Type constraints determine what operations a type parameter can perform. Constraints are essentially interfaces:

The any Constraint

any is an alias for interface{} (introduced in Go 1.18), meaning "no constraint":

func Print[T any](val T) {
    fmt.Println(val)
}

The comparable Constraint

A built-in constraint indicating the type supports == and !=:

func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

comparable includes all basic types (int, string, bool, etc.), pointers, channels, arrays (with comparable elements), and structs (with all comparable fields), but excludes slices, maps, and functions.

Custom Constraints

Define constraints using interfaces, which can contain method sets and type sets:

// Method constraint
type Stringer interface {
    String() string
}

func PrintAll[T Stringer](items []T) {
    for _, item := range items {
        fmt.Println(item.String())
    }
}

// Type constraint (using ~ for underlying type)
type Number interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
    ~float32 | ~float64
}

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

// Meaning of ~: underlying type matching
type Celsius float64  // underlying type is float64
type Fahrenheit float64

// Sum([]Celsius{20.0, 30.0}) works because ~float64 includes Celsius

The constraints Package (Experimental)

golang.org/x/exp/constraints provides common constraints:

import "golang.org/x/exp/constraints"

// constraints.Ordered: types supporting < > <= >=
// constraints.Integer: all integer types
// constraints.Float: all floating-point types
// constraints.Complex: all complex types
// constraints.Signed: all signed integers
// constraints.Unsigned: all unsigned integers

Note: Go 1.21 added min and max as built-in functions, partially reducing dependence on constraints.Ordered.

Common Mistakes and Fixes

Mistake 1: Adding new type parameters on methods

type Container[T any] struct {
    items []T
}

// Error: methods cannot introduce new type parameters
func (c *Container[T]) Convert[R any](fn func(T) R) []R { // compile error!
    // ...
}

// Correct: use a top-level function
func Convert[T any, R any](c *Container[T], fn func(T) R) []R {
    result := make([]R, len(c.items))
    for i, item := range c.items {
        result[i] = fn(item)
    }
    return result
}

Go methods cannot introduce type parameters beyond those of the receiver type โ€” this is a design limitation, not a bug.

Mistake 2: Insufficient constraints causing compilation failure

// Error: any doesn't support the + operation
func Add[T any](a, b T) T {
    return a + b // compile error: operator + not defined on T
}

// Correct: use appropriate constraint
type Addable interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
    ~float32 | ~float64 | ~complex64 | ~complex128 |
    ~string
}

func Add[T Addable](a, b T) T {
    return a + b
}

Mistake 3: Forgetting the ~ prefix

type MyInt int

// Error: only matches int itself, not MyInt
type IntOnly interface {
    int
}

// Correct: matches all types with int as underlying type
type IntLike interface {
    ~int
}

Mistake 4: Attempting type assertion on generic parameter

// Error: cannot type-assert on type parameter
func Process[T any](val T) {
    if s, ok := val.(string); ok { // compile error!
        fmt.Println(s)
    }
}

// Correct: convert to interface{} first
func Process[T any](val T) {
    if s, ok := any(val).(string); ok {
        fmt.Println(s)
    }
}

Level 2: How It Works Under the Hood

Compilation Implementation of Generics

The Go compiler needs to transform generic code into executable machine code. There are two main strategies:

Strategy 1: Monomorphization

Generate a separate copy of function code for each concrete type. C++ templates and Rust generics use this strategy.

Max[int]     โ†’ MaxInt(a, b int) int
Max[float64] โ†’ MaxFloat64(a, b float64) float64
Max[string]  โ†’ MaxString(a, b string) string

Pros: Generated code is optimized for concrete types, optimal performance. Cons: Code bloat โ€” N type instantiations mean N copies of code.

Strategy 2: Type Erasure + Dictionary Passing

Generate only one copy of generic code, look up type information at runtime through a "dictionary." Java generics use type erasure.

Pros: Small code size. Cons: Performance overhead from indirect calls, prevents inlining optimization.

Go's Choice: GC Shape Stenciling

Go 1.18 adopted a hybrid strategy called GC Shape Stenciling (described by Keith Randall in a 2021 design document). Core idea:

  1. Group by GC Shape: Types with the same memory layout (size and pointer bitmap) share one code copy
  2. Dictionary passing: Runtime obtains type-specific information through a dictionary parameter

What is a GC Shape? It's the type's "shape" from the garbage collector's perspective โ€” including size, alignment, and which offset positions contain pointers. For example:

Actual behavior:

func Max[T constraints.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

// Compiler might generate:
// Max_shape_int(a, b int, dict *typeDict) int   โ€” for int, int64, etc.
// Max_shape_ptr(a, b unsafe.Pointer, dict *typeDict) unsafe.Pointer โ€” for pointer types
// Max_shape_string(a, b string, dict *typeDict) string โ€” string has unique GC shape

Information stored in the dictionary:

Performance Impact

GC Shape Stenciling performance characteristics (based on Go 1.18-1.22 benchmarks):

// Benchmark: generic vs concrete type vs interface{}
func BenchmarkMaxGeneric(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = Max(i, i+1)
    }
}

func BenchmarkMaxConcrete(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = MaxInt(i, i+1)
    }
}

func BenchmarkMaxInterface(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = MaxIface(i, i+1)
    }
}

Typical results (Go 1.22, amd64):

Method ns/op Allocations
Concrete type ~1.5 0
Generic ~2.0 0
interface{} ~5.0 1 alloc

The generic version's extra overhead comes from dictionary lookup and indirect calls โ€” typically only 20-50% overhead, far less than interface{}'s boxing overhead. But in extremely hot paths, this difference can matter.

Compiler Optimization Evolution

Go 1.18's initial implementation was conservative; subsequent versions continue to optimize:

How Type Inference Works

Go's type inference allows omitting explicit type arguments in many cases:

// No need to write Max[int](3, 5)
result := Max(3, 5) // compiler infers T = int

The inference algorithm is based on Unification โ€” implemented by Robert Griesemer in Go 1.18. The process:

  1. Collect types of all function arguments
  2. Unify actual types with formal parameter type expressions
  3. Solve for concrete types of type parameters
func Map[T any, R any](s []T, fn func(T) R) []R

// Call: Map([]int{1,2,3}, strconv.Itoa)
// Inference process:
//   s has type []int โ†’ T = int
//   fn has type func(int) string โ†’ R = string
//   Result: Map[int, string]

Inference isn't omnipotent โ€” some cases require explicit specification:

// When type parameter doesn't appear in function arguments, cannot infer
func New[T any]() *T {
    return new(T)
}
p := New[int]() // must specify explicitly

Internal Representation of Constraints

Type constraints are internally represented as Type Sets. Go 1.18 redefined interface semantics:

// This constraint's type set = {all types with underlying type int or string}
type IntOrString interface {
    ~int | ~string
}

// Constraint's type set = method set โˆฉ type set
type OrderedStringer interface {
    ~int | ~string
    String() string
}
// Type set = {underlying type int or string} โˆฉ {types implementing String()}
// = {types with underlying type int or string that implement String()}

This means:

type MyInt int
func (m MyInt) String() string { return fmt.Sprintf("%d", m) }

// MyInt satisfies OrderedStringer:
// 1. Underlying type is int (satisfies ~int)
// 2. Implements String() string (satisfies method constraint)

Generics and Reflection

Generic types still have complete type information at runtime:

func TypeName[T any]() string {
    var zero T
    return reflect.TypeOf(&zero).Elem().String()
}

fmt.Println(TypeName[int]())    // "int"
fmt.Println(TypeName[string]()) // "string"

Note that the reflection type of an instantiated generic type differs from the "generic type with type parameters" concept โ€” the reflect package sees the instantiated concrete type, not a parameterized generic type.

Level 3: What the Specification Says

History of Go Generics Proposals (12 Years of Evolution)

Discussion about generics in Go started when Go was open-sourced in 2009. Here are the key milestones:

2010: Ian Lance Taylor's First Proposal

Ian Lance Taylor submitted the first generics proposal less than a year after Go was open-sourced. It used C++-template-like syntax:

// 2010 proposal syntax (never implemented)
gen [T] func Max(a, b T) T {
    if a > b {
        return a
    }
    return b
}

Rejected because: inconsistent with Go's existing syntax style, unclear compilation strategy.

2013: Type Functions Proposal

Proposed "type functions" as constraints:

// 2013 proposal (never implemented)
type Addable(T) interface {
    +(T, T) T
}

Rejected because: too complex, introduced operators as interface methods.

2016: GopherCon Generics Survey

The Go team released a user survey at GopherCon 2016. "Generics" ranked as the #1 most-wanted feature for multiple consecutive years. But Rob Pike emphasized:

"The generic dilemma is this: do you want slow programmers, slow compilers and bloated binaries, or slow execution times?"

2018: Draft Design with Contracts

Ian Lance Taylor proposed the "Contracts" design โ€” using separate contract declarations to define constraints:

// 2018 Contracts proposal (never implemented)
contract Ordered(T) {
    T int, int8, int16, int32, int64,
      uint, uint8, uint16, uint32, uint64,
      float32, float64, string
}

func Max(type T Ordered)(a, b T) T {
    if a > b {
        return a
    }
    return b
}

Rejected because: Contracts added an entirely new language concept with heavy learning burden; type keyword in parameter position looked strange.

June 2019: Ian Lance Taylor's Final Proposal

Ian Lance Taylor and Robert Griesemer proposed a simplified design โ€” using interfaces as constraints โ€” which became the accepted proposal. Core changes:

  1. No new keywords (contract โ†’ existing interface)
  2. [T Constraint] instead of (type T Constraint)
  3. Interfaces can contain type lists (later evolved into ~T and | syntax)
// 2019 proposal evolved into final syntax
type Ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
    ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
    ~float32 | ~float64 | ~string
}

func Max[T Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

March 2022: Go 1.18 Official Release

After 3 years of prototype implementation, community feedback, and iteration, Go 1.18 officially shipped generics.

Comparison with Other Languages' Generic Systems

C++ Templates (1990s)

template <typename T>
T Max(T a, T b) {
    return (a > b) ? a : b;
}

C++ templates are a purely compile-time text substitution mechanism ("duck typing at compile time") โ€” no constraint declarations needed; if an operation isn't supported during template instantiation, it errors.

Differences from Go:

Java Generics (2004, Java 5)

public <T extends Comparable<T>> T max(T a, T b) {
    return a.compareTo(b) > 0 ? a : b;
}

Java uses type erasure โ€” generic information is erased after compilation, only Object remains at runtime:

// After compilation, equivalent to
public Comparable max(Comparable a, Comparable b) {
    return a.compareTo(b) > 0 ? a : b;
}

Differences from Go:

Rust Generics (2015, Rust 1.0)

fn max<T: PartialOrd>(a: T, b: T) -> T {
    if a > b { a } else { b }
}

Rust uses full monomorphization โ€” one code copy per concrete type:

Differences from Go:

Key Definitions in Go Generics Specification

The Go Language Specification (Go 1.18+) core definitions for generics:

Type Parameter Lists:

A type parameter list declares type parameters for a generic function or type definition. The type parameter list looks like an ordinary function parameter list except that the type parameter names must all be present and the list is enclosed in square brackets rather than parentheses.

Type Constraints:

A type constraint is an interface that defines the set of permissible type arguments for the respective type parameter and controls the operations supported by values of that type parameter.

Type Sets:

Every type has a type set. The type set of a non-interface type T consists of just T itself. The type set of an interface type is defined by the interface's elements.

Instantiation:

A generic function or type is instantiated by substituting type arguments for the type parameters. Instantiation proceeds in two steps: (1) each type argument is substituted for its corresponding type parameter; (2) each type argument is verified to satisfy its constraint.

Design Decision Background

Go generics design has several key "non-goals" that help explain its limitations:

  1. No template metaprogramming: Go doesn't want to repeat C++'s mistake โ€” template metaprogramming makes code unreadable
  2. No specialization: Cannot provide special implementations of generic functions for specific types
  3. No variance: No Java-like ? extends T / ? super T
  4. No higher-kinded types: Cannot pass type constructors as parameters
  5. No method type parameters: Methods cannot declare new type parameters

Rob Pike explained the reasoning in an interview:

"Complexity is multiplicative. Every feature you add interacts with every other feature. By keeping generics simple, we keep the total complexity of the language manageable."

Level 4: Edge Cases and Pitfalls

When to Use Generics

Generics are most suitable for these scenarios:

Scenario 1: Generic Data Structures

// Generic linked list
type Node[T any] struct {
    Value T
    Next  *Node[T]
}

type LinkedList[T any] struct {
    Head *Node[T]
    Len  int
}

func (l *LinkedList[T]) Prepend(val T) {
    l.Head = &Node[T]{Value: val, Next: l.Head}
    l.Len++
}

Scenario 2: Generic Algorithms

// Generic filter
func Filter[T any](slice []T, predicate func(T) bool) []T {
    var result []T
    for _, v := range slice {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

// Generic reduce
func Reduce[T any, R any](slice []T, initial R, fn func(R, T) R) R {
    result := initial
    for _, v := range slice {
        result = fn(result, v)
    }
    return result
}

Scenario 3: Type-Safe Containers/Pools

// Generic sync.Pool
type Pool[T any] struct {
    pool sync.Pool
}

func NewPool[T any](newFunc func() T) *Pool[T] {
    return &Pool[T]{
        pool: sync.Pool{
            New: func() interface{} {
                return newFunc()
            },
        },
    }
}

func (p *Pool[T]) Get() T {
    return p.pool.Get().(T)
}

func (p *Pool[T]) Put(val T) {
    p.pool.Put(val)
}

When NOT to Use Generics

Unsuitable Scenario 1: Only one type will ever be used

// Unnecessary generic โ€” only string will use this
func ToUpper[T ~string](s T) T {
    return T(strings.ToUpper(string(s)))
}

// Just write it directly
func ToUpper(s string) string {
    return strings.ToUpper(s)
}

Unsuitable Scenario 2: Behavior differs drastically by type

// Wrong: completely different logic per type
func Process[T int | string | []byte](val T) {
    switch v := any(val).(type) {
    case int:
        // completely different logic
    case string:
        // completely different logic
    case []byte:
        // completely different logic
    }
}

// Right: write three separate functions
func ProcessInt(val int) { ... }
func ProcessString(val string) { ... }
func ProcessBytes(val []byte) { ... }

If you're writing a type switch inside a generic function, you're using generics wrong.

Unsuitable Scenario 3: Need runtime dispatch

// Interfaces are better: need runtime polymorphism
type Handler interface {
    Handle(ctx context.Context) error
}

// No generics needed โ€” different Handlers' behavior is decided at runtime
func RunHandlers(handlers []Handler) {
    for _, h := range handlers {
        h.Handle(context.Background())
    }
}

Known Limitations of Generics

Limitation 1: No Method Type Parameters

type Container[T any] struct{ items []T }

// Won't compile: methods cannot have their own type parameters
func (c *Container[T]) Map[R any](fn func(T) R) *Container[R] {
    // ...
}

Workaround: Use top-level functions:

func MapContainer[T any, R any](c *Container[T], fn func(T) R) *Container[R] {
    result := &Container[R]{items: make([]R, len(c.items))}
    for i, item := range c.items {
        result.items[i] = fn(item)
    }
    return result
}

Limitation 2: No Specialization

// Cannot do: provide a more efficient implementation for specific types
func Sort[T constraints.Ordered](s []T) {
    // Want: if T is int, use radix sort
    // Reality: can only use generic comparison sort
    slices.Sort(s)
}

Limitation 3: Cannot Use Generic Parameter as type switch case

func Check[T any](val any) {
    switch val.(type) {
    case T: // compile error: cannot use type parameter in type switch
        fmt.Println("match")
    }
}

Limitation 4: Constraint Interfaces Cannot Be Used as Regular Interface Types

type Number interface {
    ~int | ~float64
}

// Error: interfaces with type elements cannot be used as variable types
var n Number // compile error

// Can only be used as constraints
func Add[T Number](a, b T) T { return a + b }

Interfaces with type elements (~int | ~float64) can only be used as generic constraints, not as regular interface types. This is because such interfaces' type sets cannot be effectively represented at runtime.

Limitation 5: Generic Type Aliases (before Go 1.23)

type Pair[T any] struct{ First, Second T }

// Not legal before Go 1.23
type IntPair = Pair[int] // Go 1.24+ supports generic type aliases

Interview Questions

Question 1: Does the following code compile?

func Swap[T any](a, b *T) {
    *a, *b = *b, *a
}

Answer: Yes, it compiles. The any constraint allows all types, and assignment is legal for all types.

Question 2: Why can't you write func (s Stack[T]) Pop[R any]() R?

Answer: The Go specification explicitly forbids methods from introducing new type parameters. Two reasons:

  1. Interface compatibility: If methods have extra type parameters, interface type set definitions become extremely complex
  2. Compilation complexity: Requires vtable support for parameterized method dispatch, conflicting with Go's simple runtime model

Question 3: What's the trap with the comparable constraint and ==?

func Equal[T comparable](a, b T) bool {
    return a == b
}

// This compiles but might panic at runtime:
type I interface{ comparable }
var a I = []int{1, 2, 3} // can't โ€” interface constraints are compile-time

Since Go 1.20, the comparable constraint's behavior is stricter: only types that provably support == at compile time satisfy it. Types containing potentially-panicking == (like interfaces containing slices) don't satisfy comparable.

Question 4: When does generic function type inference fail?

// 1. Type parameter only appears in return value:
func Zero[T any]() T { var zero T; return zero }
// Must write Zero[int](), cannot infer

// 2. Type parameter in nested position:
func MakeSlice[T any](fn func() T) []T { return []T{fn()} }
// MakeSlice(func() int { return 1 }) โ€” can infer

// 3. Cannot infer from nil:
func First[T any](s []T) T { return s[0] }
// First(nil) โ€” cannot infer, nil has no type information

Real-World Case: Standard Library Genericization

Go 1.21 introduced the slices and maps packages โ€” the standard library's first large-scale use of generics:

// slices package
func Sort[S ~[]E, E cmp.Ordered](x S)
func Contains[S ~[]E, E comparable](s S, v E) bool
func Index[S ~[]E, E comparable](s S, v E) int
func Compact[S ~[]E, E comparable](s S) S

// maps package
func Keys[M ~map[K]V, K comparable, V any](m M) []K
func Values[M ~map[K]V, K comparable, V any](m M) []V
func Clone[M ~map[K]V, K comparable, V any](m M) M

These packages demonstrate Go generics best practices:

Performance Pitfall: Interface Constraints vs Type Constraints

// Approach A: Interface constraint (dispatch through methods)
type Hasher interface {
    Hash() uint64
}

func HashAll[T Hasher](items []T) []uint64 {
    result := make([]uint64, len(items))
    for i, item := range items {
        result[i] = item.Hash() // may involve indirect call through itab
    }
    return result
}

// Approach B: Type constraint (inline expansion)
type IntOrString interface {
    ~int | ~string
}

func Size[T IntOrString](val T) int {
    // Compiler can directly generate code for int/string
    return int(unsafe.Sizeof(val))
}

Method calls in Approach A may not be inlined (depending on compiler optimization), while type constraints in Approach B give the compiler more optimization room.

Best Practices Summary

  1. Use interfaces first, generics when interfaces aren't enough โ€” interfaces are Go's core abstraction mechanism; generics supplement them
  2. Be precise with constraints โ€” don't use any for all parameters; give the compiler enough information
  3. Methods cannot have new type parameters โ€” hard limitation; work around with top-level functions
  4. Beware comparable traps โ€” == on interface types might panic
  5. Watch for dictionary overhead in performance-critical paths โ€” in extreme cases, consider hand-written concrete type versions
  6. Don't over-genericize โ€” if only 2-3 types will use it, writing concrete functions may be clearer
Rate this chapter
4.8  / 5  (54 ratings)

๐Ÿ’ฌ Comments