A Practical Introduction to Go Generics

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Go 1.24

Generics arrived in Go 1.18, and Go 1.21 added cmp, slices and maps to the standard library. They are not a universal answer: they solve one implementation serving several types without giving up type safety.

Two shapes of the same problem

// 1) one function per type
func SumInts(xs []int) int { /* ... */ }
func SumFloats(xs []float64) float64 { /* ... */ }  // and it keeps growing

// 2) any plus type assertions
func SumAny(xs []any) any {
    total := 0
    for _, x := range xs {
        total += x.(int)          // blows up at run time
    }
    return total
}

Generics turn the type into a compile-time parameter, so there is neither duplication nor a loss of checking.

The first generic function

func Sum[T int | float64](xs []T) T {
    var total T
    for _, x := range xs {
        total += x
    }
    return total
}

fmt.Println(Sum([]int{1, 2, 3}))        // 6
fmt.Println(Sum([]float64{1.5, 2.5}))   // 4
  • T is a type parameter and int | float64 is its constraint. The constraint is what makes + legal.
  • Calls rarely need Sum[int](...); the compiler infers T from the argument.
  • var total T yields the type’s zero value — the only safe way to initialise a T.

Built-in constraints

func Contains[T comparable](xs []T, target T) bool {
    for _, x := range xs {
        if x == target {
            return true
        }
    }
    return false
}
  • any: no operations guaranteed. Usable for assignment, passing and little else.
  • comparable: supports == / !=, and is what map keys require. Slices, maps and functions do not qualify.
  • Anything more (arithmetic, ordering) needs a constraint you write or import.

Custom constraints

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

func Average[T Number](xs []T) float64 {
    if len(xs) == 0 {
        return 0
    }
    var sum T
    for _, x := range xs {
        sum += x
    }
    return float64(sum) / float64(len(xs))
}

~ includes defined types whose underlying type matches:

type Celsius float64

Average([]Celsius{20.5, 21.5})   // valid

Without ~, only the literal types are accepted and Celsius is rejected — the detail most people miss when writing constraints.

cmp.Ordered already covers everything orderable:

import "cmp"

func Max[T cmp.Ordered](xs []T) T {
    m := xs[0]
    for _, x := range xs[1:] {
        if x > m {
            m = x
        }
    }
    return m
}

Constraints can require methods

A constraint is an interface, so it can demand behaviour:

type Stringer interface {
    String() string
}

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

Constraints compose:

type Key interface {
    comparable
    String() string
}

Generic types

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
    }
    v := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return v, true
}

var s Stack[string]
s.Push("a")

Note: methods cannot introduce new type parameters. func (s *Stack[T]) Map[U any](f func(T) U) is illegal. Write a free function instead:

func Map[T, U any](xs []T, f func(T) U) []U {
    out := make([]U, 0, len(xs))
    for _, x := range xs {
        out = append(out, f(x))
    }
    return out
}

names := Map(users, func(u User) string { return u.Name })

Filter and Reduce work the same way. Before writing them, check slices — slices.Contains, slices.Index, slices.Sort and friends already exist, and maps.Keys / maps.Values cover the map cases.

Type inference

Sum([]int{1, 2})                                   // T = int
Map(users, func(u User) string { return u.Name })  // T = User, U = string

Inference fails when a type parameter only appears in the result:

func Zero[T any]() T { var z T; return z }

Zero[int]()    // must be explicit

When not to use generics

  • Only one type will ever call it. Write the concrete function; it reads better.
  • Behaviour differs per type. Use an interface and polymorphism, not a pile of type switches.
  • To save a few lines. Generic error messages get longer and the maintenance cost goes up.
  • When you need dynamic types. any plus assertions, or reflection, is the honest tool (JSON handling, for example).

Rule of thumb: reach for generics when the same logic has been copied a third time.

Common mistakes

1. Constraint too loose to allow the operation

func Add[T any](a, b T) T { return a + b }   // compile error: operator + not defined

any guarantees nothing. Constrain to Number or similar.

2. Forgetting ~

type UserID int64 does not satisfy an int64 constraint; ~int64 does.

3. Type-asserting on T

func Print[T any](v T) {
    if s, ok := v.(string); ok { ... }   // illegal: T is not an interface value
}

Needing a type switch is a sign the function should not be generic — or that T should be constrained to an interface and use its methods.

4. Generic methods

Go has no method-level type parameters. Only types and functions can take them.

5. Assuming nil

var zero T is not necessarily nil; for T = int it is 0. Use *T or (T, bool) when “unset” must be distinguishable.

6. Over-abstraction

A signature like Repository[T any, ID comparable, ...] makes readers solve equations. Past two type parameters, reconsider the design.

7. Ignoring the standard library

slices, maps and cmp cover sorting, searching, deduplication and comparison. Check them before writing your own.

Summary

  • Type parameters let one implementation serve many types; the constraint decides which operations are legal.
  • any allows nothing, comparable allows ==, cmp.Ordered allows ordering; use ~ to include defined types.
  • Generic types can have methods, but methods cannot add type parameters.
  • Initialise values with var zero T; type assertions on T are not allowed.
  • Copy it twice, then generalise — and prefer the standard library.

Next: putting these pieces together in a command-line tool.