Back to Notes

Go Generics

Overview

Generics (introduced in Go 1.18) let you write functions and types that work across multiple types without duplicating code. The key idea: type parameters — placeholders for concrete types supplied at call/instantiation time, constrained by interfaces.


Generic Functions

Type parameters are declared in square brackets before the regular parameter list. The constraint (any, comparable, or a custom interface) restricts which types are valid.

// Without generics — one function per type
func SumInts(s []int) int { ... }
func SumFloats(s []float64) float64 { ... }

// With generics — one function for all numeric types
func Sum[T int | float64](s []T) T {
    var total T
    for _, v := range s {
        total += v
    }
    return total
}

fmt.Println(Sum([]int{1, 2, 3}))         // 6
fmt.Println(Sum([]float64{1.1, 2.2}))    // 3.3

Go infers the type argument from the arguments — you rarely need to specify it explicitly. But you can:

Sum[int]([]int{1, 2, 3})   // explicit — usually unnecessary

Constraints

A constraint is an interface that defines what a type parameter must support. Built-in constraints:

ConstraintMeaning
anyAlias for interface{} — any type
comparableTypes that support == and != (maps, slices excluded)
// comparable constraint — needed for map keys or equality checks
func Contains[T comparable](s []T, target T) bool {
    for _, v := range s {
        if v == target {
            return true
        }
    }
    return false
}

fmt.Println(Contains([]string{"a", "b", "c"}, "b")) // true
fmt.Println(Contains([]int{1, 2, 3}, 5))            // false

Type-Set Interfaces — A New Form of Interface

Go 1.18 didn't just add generics — it also extended the meaning of interfaces. Traditional interfaces are method-based: a type satisfies the interface if it implements the required methods. This still works exactly as before.

// Traditional — method-based (works anywhere)
type Stringer interface {
    String() string
}

But constraints need more than methods — to use < on a type parameter T, the compiler needs to know T is ordered. You can't express that with methods alone. So Go introduced type-set interfaces: instead of listing methods, they list the exact concrete (or underlying) types that are permitted.

// Type-set interface — lists allowed types
type Ordered interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64 |
    string
}

func Min[T Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

fmt.Println(Min(3, 5))       // 3
fmt.Println(Min("b", "a"))   // a

Because the compiler knows every type in Ordered supports <, it allows a < b inside the function.

The key distinction:

Interface typeWhere it can be usedWhat it expresses
Method-basedAnywhere (variables, params, returns)Behaviour — what a type can do
Type-setOnly as a constraint on type parametersIdentity — which concrete types are allowed

You cannot use a type-set interface as a regular variable type (var x Ordered is a compile error) — they exist solely to constrain generics.


Custom Constraints with constraints Package / Union Types

You can define a constraint interface using a union of types with |:

type Number interface {
    int | int8 | int16 | int32 | int64 |
        float32 | float64
}

func Min[T Number](a, b T) T {
    if a < b {
        return a
    }
    return b
}

The golang.org/x/exp/constraints package provides ready-made constraints: constraints.Ordered, constraints.Integer, constraints.Float, etc.


~T — Underlying Type Constraint

~T means "any type whose underlying type is T". This lets your generic function work with custom types built on top of primitives.

type Celsius float64
type Fahrenheit float64

type Temperature interface {
    ~float64   // matches float64, Celsius, Fahrenheit, etc.
}

func Average[T Temperature](temps []T) T {
    var sum T
    for _, t := range temps {
        sum += t
    }
    return sum / T(len(temps))
}

fmt.Println(Average([]Celsius{20, 25, 30}))  // 25

Without ~, Celsius would not satisfy float64 — only the exact float64 type would.


Generic Types (Structs)

Type parameters work on struct definitions too:

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

s := Stack[int]{}
s.Push(1)
s.Push(2)
v, _ := s.Pop()
fmt.Println(v) // 2

Generic Interfaces (Parametric Constraints)

Interfaces can have type parameters too — not just structs and functions. This lets you define a contract that is itself parameterised, so different implementations can work with different concrete types while sharing the same interface shape.

type item interface {
    Title() string
    Availability() bool
}

// library is a generic interface — parameterised by the item type it lends
type library[I item] interface {
    Lend(I)
}

Here library[I item] says: "a library that lends things of type I, where I must satisfy the item interface." Two completely separate implementations can satisfy library with different item types:

type bookLibrary struct{ booksLent []book }
func (bl *bookLibrary) Lend(b book) { bl.booksLent = append(bl.booksLent, b) }

type audioLibrary struct{ audioBooksLent []audioBook }
func (al *audioLibrary) Lend(ab audioBook) { al.audioBooksLent = append(al.audioBooksLent, ab) }

A generic function can then accept any library[I] without caring which concrete type it is:

func lendItems[I item](l library[I], items []I) {
    for _, it := range items {
        l.Lend(it)
    }
}

// Works for both
lendItems[book](&bl, []book{ ... })
lendItems[audioBook](&al, []audioBook{ ... })

The type parameter on the interface ([I item]) propagates into the generic function — the compiler verifies that l and items agree on the same concrete type I at compile time.

This pattern is useful when you have a family of types (books, audio books, e-books) that all satisfy a shared behaviour interface (item), but each needs its own strongly-typed container.


Multiple Type Parameters

A function or type can have more than one type parameter:

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
}

doubled := Map([]int{1, 2, 3}, func(n int) int { return n * 2 })
strs    := Map([]int{1, 2, 3}, func(n int) string { return fmt.Sprintf("%d", n) })
fmt.Println(doubled) // [2 4 6]
fmt.Println(strs)    // [1 2 3]

When to Use Generics

Use generics whenAvoid generics when
Writing reusable data structures (Stack, Queue, Set)The function only works for one concrete type
Utility functions over slices/maps (Map, Filter, Reduce)The logic relies on runtime behaviour (reflection, type switches)
Avoiding code duplication across numeric typesPremature abstraction — start concrete, generalise when needed

Gotchas

GotchaDetail
any doesn't support operators+, <, == on any won't compile — use a proper constraint
Type inference limitsInference works for function args, not always for return types — may need explicit type args
No generic methodsMethods cannot introduce new type parameters — only the receiver's type params are in scope
Generics ≠ fasterGeneric code is not necessarily faster than interface-based code; benchmark before optimising
Zero value of type paramUse var zero T to get the zero value of a type parameter