IndexAdvanced Go PatternsPart 07

Generics

Constraints, inference, and when not to reach for them.

Apr 22, 20265 min readTypesPart 07 of 11

Generics landed in Go and the community promptly split into "finally!" and "please don't." Both reactions are right, depending on what you do with them. Used well, generics remove a whole category of interface{} boilerplate and unsafe type assertions. Used carelessly, they make code harder to read for no benefit.

This post is about the good uses, the constraints system, and — just as important — when to leave generics in the drawer.

The problem they solve

Before generics, a reusable container or utility meant interface{} (now any) and runtime type assertions:

Go
// Pre-generics: loses type safety, requires assertions, can panic.
func First(s []any) any {
    return s[0]
}

x := First([]any{1, 2, 3}) // x is `any` — caller must assert x.(int)

You lost compile-time type checking and paid for boxing. Generics give you the reuse and the type safety:

Go
func First[T any](s []T) T {
    return s[0]
}

x := First([]int{1, 2, 3}) // x is int, checked at compile time
y := First([]string{"a"})  // y is string

[T any] declares a type parameter. The compiler infers T from the argument, so you rarely write it explicitly. No assertions, no boxing, full type safety.

One generic function · many concrete types First[T any]written once First([]int{…}) → int First([]string{…}) → string First([]User{…}) → User
The compiler infers T at each call site — no copy-paste, no assertions

Constraints: limiting what T can be

any means "any type," which only lets you do things every type supports (assign, pass around). To actually operate on a T — add it, compare it — you constrain it to types that support those operations.

A constraint is an interface that may also list concrete types:

Go
// Numeric is satisfied by any of these underlying types.
type Numeric interface {
    ~int | ~int64 | ~float64
}

func Sum[T Numeric](nums []T) T {
    var total T
    for _, n := range nums {
        total += n // legal: every type in Numeric supports +
    }
    return total
}

The ~ means "any type whose underlying type is this," so a type Celsius float64 also satisfies ~float64. Without the ~, only the exact type float64 would qualify.

A constraint admits a set of types Numeric~int | ~int64 | ~float64 admits int, int64, float64 type Celsius float64 (via ~ underlying)
The ~ prefix widens a union to every type with that underlying type

The standard library ships ready-made constraints in constraints and, most usefully, comparable (built in) for anything usable as a map key or with ==:

Go
// comparable is a built-in constraint: types that support == and !=.
func Contains[T comparable](s []T, target T) bool {
    for _, v := range s {
        if v == target {
            return true
        }
    }
    return false
}

For ordering, cmp.Ordered (from the standard cmp package) covers everything that supports <, >, etc.:

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

(Note: min and max are now built-in for scalars; write your own generic version only when you need it over custom orderings or slices.)

Where generics genuinely shine

1. Collection / slice / map utilities. The poster child. The standard slices and maps packages are built on generics:

Go
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
}

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

names := Map([]User{...}, func(u User) string { return u.Name })
adults := Filter(users, func(u User) bool { return u.Age >= 18 })

2. Type-safe containers. A generic stack, set, ordered cache, or tree eliminates per-type duplication or any-based versions:

Go
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
}

Note var zero T — the idiomatic way to produce the zero value of a type parameter when you have nothing to return.

3. Removing any from data-structure-ish code. Anywhere you were reaching for any plus a type assertion to write something reusable, a type parameter is almost always clearer and safer.

When NOT to use generics

This is the part people skip, so read it twice. Generics are a tool for removing duplication across types. If that's not your problem, they add complexity for nothing.

  • An interface already expresses what you need. If you only call methods on a value, an interface is simpler and more idiomatic. io.Reader doesn't need to be generic. Reach for generics when you need to operate on a value generically (compare it, do arithmetic, store it in a typed container) — not just call its methods.
  • There's only one type in practice. Don't make a function generic "in case" you need another type later. Add the type parameter when the second type actually shows up. A concrete function is easier to read.
  • It hurts readability. A signature like func F[T any, U comparable, V cmp.Ordered](...) with three constraints is a sign you've gone too far. If a reader has to decode the type machinery to understand what the function does, reconsider.
  • For methods that vary by type. Go does not allow type parameters on individual methods (only on the type). If you need polymorphic behavior per type, that's what interfaces are for.

A useful heuristic: interfaces are about behavior; generics are about types. If you're abstracting over "things that can do X," use an interface. If you're abstracting over "a container/algorithm that works for any type," use generics.

Rule of thumb

Interfaces for behavior, generics for types. Don't make something generic until a second type actually demands it — and never trade readability for abstraction you don't yet need.

A quick note on performance

Don't choose generics for performance, and don't avoid them for performance — the effect is usually negligible and implementation-dependent. Choose them for type safety and reduced duplication. If a hot path's allocations matter, profile it (post 8) rather than guessing about generic vs. interface dispatch.

The takeaway

Generics let you write reusable, type-safe code without interface{} and runtime assertions — ideal for slice/map utilities and typed containers. Constrain your type parameters to exactly the operations you need (comparable, cmp.Ordered, or a custom ~type union), and use var zero T for zero values. But remember the dividing line: interfaces for behavior, generics for types. Don't make something generic until a second type actually demands it, and never trade readability for abstraction you don't yet need.