DEV Community

Cover image for Go Generics in 2026: When Type Sets Earn Their Keep
Gabriel Anhaia
Gabriel Anhaia

Posted on

Go Generics in 2026: When Type Sets Earn Their Keep


You've seen the pull request. Someone took a working function that
accepted an interface{} and rewrote it with a type parameter and
a constraint interface and a comparable bound, and now the diff is
forty lines longer and the reviewer is squinting at [T Ordered]
trying to decide whether it's better or just newer.

Generics landed in Go 1.18, four years ago now. The "should I use
them" hype cycle is over. What's left is the boring engineering
question: which code gets clearer with type parameters, and which
code gets worse. The answer turns on type sets, the part of the
generics design most people skip.

Type sets are the actual feature

Before generics, a Go interface was a method set. io.Reader is
"anything with a Read method." That's still true. What 1.18 added
is the ability for an interface to also describe a set of types,
not just a set of methods.

type Number interface {
    ~int | ~int64 | ~float64
}
Enter fullscreen mode Exit fullscreen mode

That interface has no methods. It says: the type set is int,
int64, float64, and any named type whose underlying type is one
of those. The ~ is the part that matters. ~int means "int and
everything defined as type Celsius int." Drop the tilde and you
exclude every named type, which is almost never what you want.

A type parameter constrained by Number can do the arithmetic that
the constituent types share:

func Sum[T Number](xs []T) T {
    var total T
    for _, x := range xs {
        total += x
    }
    return total
}
Enter fullscreen mode Exit fullscreen mode

total += x compiles because every type in the set supports +.
You could not write this with a method-only interface. There is no
Add method on int. Type sets are the only way to say "these
operators are available" to the compiler.

Where generics beat interfaces: containers

The clean win is data structures that hold a type without caring
what it is. Before generics, a stack of anything meant
[]interface{} and a type assertion on every read.

type Stack struct {
    items []interface{}
}

func (s *Stack) Push(x interface{}) {
    s.items = append(s.items, x)
}

func (s *Stack) Pop() interface{} {
    n := len(s.items) - 1
    x := s.items[n]
    s.items = s.items[:n]
    return x
}
Enter fullscreen mode Exit fullscreen mode

Every caller pays for that design. Pop returns interface{}, so
you assert: v := stack.Pop().(int). Get the type wrong and you
panic at runtime. The compiler has nothing to check.

The generic version moves the whole problem to compile time:

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

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

func (s *Stack[T]) Pop() T {
    n := len(s.items) - 1
    x := s.items[n]
    s.items = s.items[:n]
    return x
}
Enter fullscreen mode Exit fullscreen mode

A Stack[int] only accepts ints. Pop returns an int. No
assertion, no panic, no boxing. This is the case where generics are
not a style choice. The interface version is worse along every axis
that matters, and the standard library agrees: slices, maps, and
container/list's modern replacements all went generic for exactly
this reason.

Where generics beat interfaces: numeric and ordered work

The second clean win is anything that needs operators across types.
Sum above is one. Min and Max are the classic pair. Before
1.18 you wrote one per type or pushed everything through float64
and lost precision. Now cmp.Ordered from the standard library
gives you the constraint for free:

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
}
Enter fullscreen mode Exit fullscreen mode

cmp.Ordered is a type set of every type that supports < and >:
the integer kinds, the floats, and string. Max works on
[]int, []string, []time.Duration, all from one definition.
Try this with a method interface and you're back to writing a
Less method on every type, which is more code than the thing you
were trying to avoid.

Where generics add noise: single-method behavior

Here's the inversion. The moment your function only cares about
behavior, an interface is the better tool, and a type parameter
just adds ceremony.

// Generic. Looks modern. Adds nothing.
func WriteAll[W io.Writer](w W, parts [][]byte) error {
    for _, p := range parts {
        if _, err := w.Write(p); err != nil {
            return err
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

That type parameter buys you nothing. io.Writer is already an
interface; you don't need to generalize over "kinds of writer"
because the interface already does that. The plain version is
shorter and reads the way Go is supposed to read:

func WriteAll(w io.Writer, parts [][]byte) error {
    for _, p := range parts {
        if _, err := w.Write(p); err != nil {
            return err
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The rule: if every operation you perform on the value is a method
call, use an interface. Type parameters earn their place when you
need operators, the concrete type in the return position, or a
container that stores the type without boxing it.

Where generics add noise: constraints that exist once

The other trap is writing a constraint interface that has exactly
one implementing type, or a function with a type parameter that's
only ever instantiated with one type.

type repo[T any] struct {
    db *sql.DB
}

func (r *repo[T]) FindByID(id int64) (T, error) {
    // ... only ever instantiated as repo[User]
}
Enter fullscreen mode Exit fullscreen mode

If T is always User, the parameter is a costume. It makes the
code look reusable while delivering none of the reuse, and it makes
every reader trace the instantiation to find out what T actually
is. Write repo with a concrete User and stop pretending. You can
generalize later when a second type shows up, and "later" is cheaper
than the confusion now.

The compile-time cost is real

Generics are not free at build time, and on large codebases the
cost shows up. Go implements generics with a strategy called GC
shape stenciling: the compiler generates one copy of the code per
distinct "shape" (roughly, per pointer-vs-value layout), then shares
that copy across types with the same shape. Pointers all share one
shape, so Stack[*User] and Stack[*Order] reuse the same compiled
body. Value types each get their own.

Two consequences worth knowing.

First, instantiating a generic function with many distinct value
types multiplies the generated code, which grows binary size and
compile time. A package that instantiates Sum with int,
int64, float32, float64, and a dozen named numeric types pays
for each shape. It's rarely a problem, but it is measurable on big
generic-heavy packages, and it's the reason a few hot paths in the
standard library still hand-write per-type code.

Second, the shape-sharing for pointers adds a small indirection. A
generic function operating on []*T goes through a dictionary to
find type-specific information at runtime, which can be marginally
slower than the monomorphized code a language like Rust or C++ would
emit. For most code this is noise. For a tight inner loop you've
profiled, it's a reason to benchmark the generic version against a
concrete one before assuming generics are free.

The takeaway is not "avoid generics for speed." It's "don't reach
for them expecting zero cost." If you're writing a hot path, measure.
If you're writing a container or a numeric helper, the clarity is
worth far more than the indirection costs.

A decision you can actually apply

Reach for a type parameter when one of these is true:

  • You need an operator (+, <, ==) across multiple types. That means a type set, and a type set means generics.
  • You're writing a container that stores a value and hands it back with its real type intact.
  • You'd otherwise write the same function two or more times, differing only in the type.

Reach for an interface when:

  • Every operation is a method call. io.Writer, fmt.Stringer, your own service ports.
  • The constraint would have one implementer today.
  • You want runtime polymorphism, where the concrete type is chosen at runtime, not at the call site.

Generics didn't replace interfaces. They filled the gap interfaces
couldn't reach: code that's identical across types and uses
operators or concrete return types. Outside that gap, an interface
is still the simpler tool, and simpler is the goal.


If this was useful

Type sets are one of those features that read as a footnote in the
spec and quietly change how you design half your package. The
Complete Guide to Go Programming
covers the generics model
end-to-end — type sets, the ~ operator, GC shape stenciling, and
when the compiler shares a body versus stencils a new one.
Hexagonal Architecture in Go shows where type parameters help in a
real service layout and where a plain interface keeps the ports
honest.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (1)

Collapse
 
kernelpryanic profile image
Kernel Pryanic

Brilliant! This article is insanely well saturated, pristine generics vs. interfaces guidelines with no noise what so ever. It deserves much more attention.