Go Generics — A Comprehensive Guide

A comprehensive guide to Go generics: type parameters, constraints, and type inference.

🌱 Seedling·created: ·category:Golang

Table of Contents

  1. Introduction
  2. History and Motivation
  3. Basic Syntax
  4. Type Parameters
  5. Type Constraints
  6. The constraints and cmp Packages
  7. Type Sets and Interfaces as Constraints
  8. Generic Functions
  9. Generic Types (Structs)
  10. Generic Methods and Their Limitations
  11. Type Inference
  12. Instantiation
  13. The any Type
  14. The comparable Constraint
  15. Union Elements and the ~ (Underlying Type) Operator
  16. Generic Data Structures
  17. Standard Library Generic Packages
  18. Variadic Generics and Multiple Type Parameters
  19. Performance Considerations
  20. Common Pitfalls
  21. Best Practices
  22. Generics vs. interface{} / any vs. Code Generation
  23. Real-World Examples
  24. Conclusion

Introduction

Generics were introduced in Go 1.18 (released March 2022), marking one of the largest changes to the Go language since its 1.0 release in 2012. Generics allow functions and types to operate on a range of different types while retaining full compile-time type safety, without resorting to interface{} (now any) and runtime type assertions.

Before generics, Go developers had three main workarounds:

  • Writing separate functions for every type (code duplication)
  • Using interface{} and type assertions/switches (loses compile-time safety, adds runtime overhead)
  • Using code generation tools like go generate with templates (adds build complexity)

Generics solve this by letting you write a single implementation that is type-parameterized, checked by the compiler, and (in most cases) monomorphized or dictionary-passed at compile time for reasonable performance.


History and Motivation

The Go team explored generics design for many years. Key milestones:

  • 2010: Early discussions about generics started almost immediately after Go’s public release.
  • 2018–2019: The “type parameters” draft design was published, going through several iterations (including a rejected “contracts” proposal).
  • June 2021: The finalized proposal was accepted for Go 1.18.
  • March 2022: Go 1.18 shipped with generics, type parameters, constraints, and the slices/maps experimental packages (golang.org/x/exp/slices, golang.org/x/exp/maps).
  • Go 1.21 (2023): The slices, maps, cmp, and min/max builtins were promoted to the standard library.

The core motivation was to eliminate code duplication for container types and algorithms (e.g., a generic Stack[T], Map(), Filter(), Reduce()) while preserving Go’s philosophy of simplicity and fast compilation.


Basic Syntax

A generic function declares type parameters in square brackets right after the function name:

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

Breaking this down:

  • [T constraints.Ordered] — declares a type parameter named T, constrained to types that satisfy constraints.Ordered (i.e., support <, >, etc.)
  • (a, b T) — the function parameters are both of type T
  • The return type is also T

Calling it:

result := Max[int](3, 7)      // explicit instantiation
result2 := Max(3, 7)          // implicit — compiler infers T = int

Type Parameters

Type parameters behave like regular parameters but represent types instead of values. They are declared inside square brackets, and each type parameter has:

  1. A name (by convention, a single uppercase letter like T, K, V, E)
  2. A constraint that limits which types can be substituted for it
func Print[T any](s []T) {
    for _, v := range s {
        fmt.Println(v)
    }
}

Multiple type parameters are comma-separated:

func MapKeys[K comparable, V any](m map[K]V) []K {
    keys := make([]K, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

Naming conventions commonly used in the Go ecosystem:

  • T — a generic “Type”
  • K, V — Key and Value (for maps)
  • E — Element (for slices)
  • S — a Slice type parameter (e.g., S ~[]E)

Type Constraints

A constraint is an interface type that specifies the set of permissible type arguments and the set of operations supported by values of that type. Constraints can be:

  1. Predeclared: any (alias for interface{}), comparable
  2. Defined via interfaces with method sets (like regular Go interfaces)
  3. Defined via interfaces with type sets (using union | and underlying-type ~ elements)
type Number interface {
    int | int8 | int16 | int32 | int64 |
        float32 | float64
}

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

Constraints can also require methods:

type Stringer interface {
    String() string
}

func Join[T Stringer](items []T, sep string) string {
    parts := make([]string, len(items))
    for i, item := range items {
        parts[i] = item.String()
    }
    return strings.Join(parts, sep)
}

Constraints can combine both method sets and type sets:

type OrderedStringer interface {
    ~int | ~string
    String() string
}

The constraints and cmp Packages

Originally, the experimental golang.org/x/exp/constraints package provided common constraints like Ordered, Signed, Unsigned, Integer, Float, and Complex.

As of Go 1.21, the standard library’s cmp package provides:

package cmp

type Ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
        ~float32 | ~float64 | ~string
}

func Compare[T Ordered](x, y T) int
func Less[T Ordered](x, y T) bool

Example usage:

import "cmp"

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

Since Go 1.21, the builtins min and max are also available natively for ordered types, without needing generics at all:

x := min(3, 7)      // 3
y := max(3.1, 7.2)  // 7.2

Type Sets and Interfaces as Constraints

Since Go 1.18, interfaces can describe type sets, not just method sets. An interface used purely as a constraint may list:

  • Union elements: int | int64 | float64 — the type argument must be one of these
  • Underlying type elements: ~int — the type argument’s underlying type must be int (this also permits defined types like type MyInt int)
type Integer interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

Without the ~, only the exact type int would satisfy the constraint — a custom type like type UserID int would not match int directly, but it would match ~int.

This distinction matters a lot in practice:

type Celsius float64

type Number interface {
    int | float64 // no ~
}

func Double[T Number](x T) T { return x * 2 }

// Double(Celsius(10)) // COMPILE ERROR: Celsius does not implement Number

vs.

type Number interface {
    ~int | ~float64 // with ~
}

func Double[T Number](x T) T { return x * 2 }

Double(Celsius(10)) // OK

Generic Functions

Generic functions are the most common use case. A few illustrative examples:

Filter:

func Filter[T any](s []T, keep func(T) bool) []T {
    result := make([]T, 0, len(s))
    for _, v := range s {
        if keep(v) {
            result = append(result, v)
        }
    }
    return result
}

Map:

func Map[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

Reduce:

func Reduce[T, U any](s []T, init U, f func(U, T) U) U {
    acc := init
    for _, v := range s {
        acc = f(acc, v)
    }
    return acc
}

Usage:

nums := []int{1, 2, 3, 4, 5}
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
squares := Map(nums, func(n int) int { return n * n })
sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })

Generic Types (Structs)

Structs, interfaces, and other type declarations can also be parameterized:

type Pair[K, V any] struct {
    Key   K
    Value V
}

func NewPair[K, V any](key K, value V) Pair[K, V] {
    return Pair[K, V]{Key: key, Value: value}
}

Generic types can be nested and composed:

type Tree[T any] struct {
    Value    T
    Children []*Tree[T]
}

type Graph[T comparable] struct {
    Nodes map[T]bool
    Edges map[T][]T
}

When you use a generic type, you must supply the type arguments (unless they can be inferred from a constructor function, as in NewPair above):

p := Pair[string, int]{Key: "age", Value: 30}

Generic Methods and Their Limitations

Methods on generic types automatically inherit the type parameters of the receiver:

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

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

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

Important limitation: Go does not allow a method itself to introduce new type parameters beyond those of its receiver. This means you cannot write generic methods with their own independent type parameter (a common frustration compared to languages like Java or C#):

type Container[T any] struct {
    value T
}

// This is NOT allowed:
// func (c Container[T]) MapTo[U any](f func(T) U) Container[U] { ... }

The workaround is to use a standalone generic function instead:

func MapContainer[T, U any](c Container[T], f func(T) U) Container[U] {
    return Container[U]{value: f(c.value)}
}

This restriction exists because Go’s method sets and interface satisfaction rules become significantly more complex if methods can have their own type parameters — it’s an active topic of possible future language evolution but is not supported today.


Type Inference

The Go compiler can often infer type arguments so you don’t need to specify them explicitly. There are two main inference mechanisms:

  1. Function argument type inference: infers type parameters from the types of regular function arguments.
  2. Constraint type inference: infers a type parameter from another type parameter’s structural constraint (e.g., inferring an element type from a slice type constraint like ~[]E).
func First[T any](s []T) T {
    return s[0]
}

x := First([]int{1, 2, 3}) // T inferred as int, no need for First[int](...)

Inference has limits — it cannot infer types purely from a function’s return type, and inference does not “flow backward” from context like variable assignment target types. When inference fails, you must specify type arguments explicitly:

var f float64 = 3.0
// n := Convert(f) // if Convert[T, U any](x T) U, U cannot be inferred!
n := Convert[float64, int](f) // must specify explicitly

Instantiation

“Instantiation” is the act of substituting concrete types for type parameters, producing a non-generic function or type:

type List[T any] []T

var IntList = List[int]{1, 2, 3}      // instantiated type
var StringList = List[string]{"a", "b"} // different instantiated type

Function values can also be instantiated without being called immediately:

var maxInt = Max[int] // maxInt has type func(int, int) int

Each unique instantiation is treated as a distinct type/function by the type system, even though they share a single generic definition in source code.


The any Type

any is simply a predeclared alias for interface{}, introduced alongside generics for readability:

type any = interface{}

any is commonly used as a constraint meaning “no restriction — any type is allowed”:

func Identity[T any](v T) T {
    return v
}

Note that any as a constraint is different from using interface{} as a regular (non-generic) parameter type — with generics, the compiler still enforces that a single concrete type is used consistently per instantiation, and you get that type back without needing a type assertion.


The comparable Constraint

comparable is a predeclared constraint satisfied by any type that supports == and !=. This includes:

  • Basic types: numbers, strings, booleans
  • Pointers
  • Channels
  • Arrays of comparable types
  • Structs whose fields are all comparable

It excludes slices, maps, and functions (which are not comparable in Go).

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

Since Go 1.20, comparable also (somewhat controversially) permits types whose comparison could panic at runtime (like interfaces holding uncomparable dynamic values), so comparable is not a 100%-panic-safe guarantee in every corner case — but for typical use it works as expected for maps keys, ==, etc.


Union Elements and the ~ (Underlying Type) Operator

A union element in a constraint interface lists alternative types separated by |:

type Numeric interface {
    int | int32 | int64 | float32 | float64
}

A type argument satisfies a union element if it is identical to (or, when combined with ~, has the same underlying type as) any one of the listed terms.

Restrictions on union elements:

  • Terms in a union cannot overlap (you can’t have both int and ~int in the same union, since the latter already includes the former)
  • You cannot include interfaces with methods inside a union that also has more than one term in some circumstances
  • A union element with a single term and no ~ behaves like a normal type restriction
// Valid:
type Ordered interface {
    ~int | ~float64 | ~string
}

// Invalid — overlapping terms:
// type Bad interface {
//     int | ~int
// }

Generic Data Structures

Generics shine when implementing reusable containers and algorithms. Common examples:

Linked List

type Node[T any] struct {
    Value T
    Next  *Node[T]
}

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

func (l *LinkedList[T]) Append(v T) {
    n := &Node[T]{Value: v}
    if l.Head == nil {
        l.Head = n
        l.Tail = n
    } else {
        l.Tail.Next = n
        l.Tail = n
    }
    l.Len++
}

Binary Search Tree

type BSTNode[T cmp.Ordered] struct {
    Value       T
    Left, Right *BSTNode[T]
}

func (n *BSTNode[T]) Insert(v T) *BSTNode[T] {
    if n == nil {
        return &BSTNode[T]{Value: v}
    }
    if v < n.Value {
        n.Left = n.Left.Insert(v)
    } else if v > n.Value {
        n.Right = n.Right.Insert(v)
    }
    return n
}

Generic Set

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

func NewSet[T comparable](items ...T) *Set[T] {
    s := &Set[T]{m: make(map[T]struct{})}
    for _, item := range items {
        s.Add(item)
    }
    return s
}

func (s *Set[T]) Add(v T)      { s.m[v] = struct{}{} }
func (s *Set[T]) Has(v T) bool { _, ok := s.m[v]; return ok }
func (s *Set[T]) Remove(v T)   { delete(s.m, v) }
func (s *Set[T]) Len() int     { return len(s.m) }

Standard Library Generic Packages

Go 1.21+ ships several generic packages:

slices

import "slices"

slices.Sort(s)
slices.Contains(s, x)
slices.Index(s, x)
slices.Reverse(s)
slices.Max(s)
slices.Min(s)
slices.Equal(s1, s2)
slices.Clone(s)
slices.Compact(s)
slices.Insert(s, i, v...)
slices.Delete(s, i, j)
slices.BinarySearch(s, x)

maps

import "maps"

maps.Keys(m)     // returns an iterator (Go 1.23+) or a slice (older)
maps.Values(m)
maps.Equal(m1, m2)
maps.Clone(m)
maps.DeleteFunc(m, f)
maps.Copy(dst, src)

cmp

import "cmp"

cmp.Compare(a, b)
cmp.Less(a, b)
cmp.Or(a, b, c) // returns first non-zero value

iter (Go 1.23+)

Introduces the Seq[T] and Seq2[K, V] iterator function types used by range-over-func, enabling generic iterators:

func Seq[T any](s []T) iter.Seq[T] {
    return func(yield func(T) bool) {
        for _, v := range s {
            if !yield(v) {
                return
            }
        }
    }
}

Variadic Generics and Multiple Type Parameters

Go does not support true “variadic type parameters” (an arbitrary number of type parameters), unlike some languages’ variadic templates. However, you can combine generics with variadic value parameters:

func NewSlice[T any](items ...T) []T {
    return items
}

Multiple type parameters with independent constraints are supported and common:

func Zip[A, B any](as []A, bs []B) []struct {
    First  A
    Second B
} {
    n := min(len(as), len(bs))
    result := make([]struct {
        First  A
        Second B
    }, n)
    for i := 0; i < n; i++ {
        result[i] = struct {
            First  A
            Second B
        }{as[i], bs[i]}
    }
    return result
}

Performance Considerations

Go’s generics implementation uses a hybrid strategy called GC shape stenciling combined with dictionary passing:

  • Types with the same “GC shape” (same size, same pointer layout) can share a single compiled implementation, with a runtime “dictionary” passed in to handle type-specific operations (like calling the right comparison function).
  • This keeps binary size more reasonable than full monomorphization (like Rust or C++ templates, which generate a separate copy of code per type) while still being faster than interface{}-based dynamic dispatch in most cases.

Key performance takeaways:

  • Generic code is generally faster than interface{}-based equivalents because it avoids boxing (heap allocation for interface values holding non-pointer types) and avoids type assertions at runtime.
  • Generic code is usually slightly slower than fully monomorphized, hand-written type-specific code, due to the dictionary-passing overhead for shape-sharing cases — though the gap has narrowed significantly with each Go release as the compiler team keeps optimizing.
  • Benchmarks (testing.B) should always be used to verify actual performance in your specific case rather than assuming.
  • Since Go 1.18, there have been ongoing compiler improvements (e.g., better inlining of generic functions) across releases, so newer Go versions tend to have better generics performance than 1.18 itself.

Common Pitfalls

  1. Trying to use T as a zero value without var zero T:

    func First[T any](s []T) T {
        if len(s) == 0 {
            return nil // COMPILE ERROR if T could be a non-nilable type
        }
        return s[0]
    }
    // Correct:
    func First[T any](s []T) T {
        var zero T
        if len(s) == 0 {
            return zero
        }
        return s[0]
    }
  2. Forgetting that methods can’t add new type parameters (see the Generic Methods section above).

  3. Assuming comparable means “always safe to use == — interface values with uncomparable dynamic types can still panic at runtime under comparable since Go 1.20’s relaxation.

  4. Overusing generics where a simple interface{}/any or even a concrete type would be clearer and sufficient — Go’s philosophy favors readability, and not every function needs to be generic.

  5. Forgetting ~ in constraints, which silently excludes custom/defined types that share the same underlying type.

  6. Confusing type constraints with type assertions — constraints are compile-time only; they don’t let you dynamically dispatch behavior at runtime the way an interface method set does.

  7. Trying to instantiate generic types with method values incorrectly — remember Stack[int]{}.Push needs a pointer receiver context correctly set up.


Best Practices

  • Don’t generic-ify prematurely. Start with concrete types; extract a generic version once you see real duplication across at least two or three types.
  • Prefer standard library generic packages (slices, maps, cmp) over hand-rolled equivalents.
  • Use meaningful but conventional type parameter names (T, K, V, E for simple cases; longer names for complex, multi-parameter generic code where clarity matters more).
  • Keep constraints as narrow as necessary, but no narrower — don’t require comparable if you only need any.
  • Document behavior around zero values explicitly since nil isn’t always valid for T.
  • Benchmark before assuming performance parity with hand-written specialized code.
  • Avoid deeply nested generic types (like Map[string, List[Pair[int, Set[string]]]]) as they hurt readability — consider named type aliases to simplify signatures.

Generics vs. interface{} / any vs. Code Generation

AspectGenericsinterface{}/anyCode Generation
Compile-time type safety✅ Full❌ None (runtime assertions needed)✅ Full
PerformanceGood (dictionary passing)Slower (boxing, dynamic dispatch)Best (fully specialized)
Code duplicationNone (single generic definition)None, but loses type safetyDuplicated generated code
Build complexityNative language featureNative language featureRequires extra build step/tooling
ReadabilityGood, if not overusedSimple but less safeCan be verbose, generated files

Real-World Examples

Generic Result/Option Type

type Result[T any] struct {
    Value T
    Err   error
}

func Ok[T any](v T) Result[T] {
    return Result[T]{Value: v}
}

func Err[T any](err error) Result[T] {
    return Result[T]{Err: err}
}

func (r Result[T]) IsOk() bool {
    return r.Err == nil
}

Generic Cache

type Cache[K comparable, V any] struct {
    mu    sync.RWMutex
    items map[K]V
}

func NewCache[K comparable, V any]() *Cache[K, V] {
    return &Cache[K, V]{items: make(map[K]V)}
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.items[key]
    return v, ok
}

func (c *Cache[K, V]) Set(key K, value V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = value
}

Generic Pipeline / Functional Composition

func Compose[A, B, C any](f func(A) B, g func(B) C) func(A) C {
    return func(a A) C {
        return g(f(a))
    }
}

double := func(x int) int { return x * 2 }
toString := func(x int) string { return fmt.Sprintf("%d", x) }
doubleThenString := Compose(double, toString)
fmt.Println(doubleThenString(5)) // "10"

Conclusion

Go generics, introduced in Go 1.18 and steadily refined through Go 1.21–1.23+, bring type-safe, reusable abstractions to the language without sacrificing its core values of simplicity and performance. While the feature set is intentionally more conservative than in languages like Rust, C++, or Java/C# (no variadic type parameters, no new type parameters on methods, limited specialization), it covers the vast majority of practical use cases: generic containers, algorithms, and utility functions.

The key to using generics well in Go is restraint: use them where they genuinely eliminate duplication or unlock reusable, type-safe abstractions — and prefer the standard library’s slices, maps, and cmp packages whenever they already solve your problem.

This note is part of the Digital Garden — a collection of connected, evolving thoughts.