- Book: The Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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
}
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
}
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
}
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
}
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
}
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
}
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
}
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]
}
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.

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