open atlas
↑ Back to track
Go, zero to senior GO · 05 · 02

When generics, when interfaces: the decision senior reviewers actually make

Interfaces for behaviour, generics for data structures and algorithms over them. The slices/maps packages as the canon, the anti-patterns — generic everything, method-only constraints — and honest performance: value-shape devirtualization vs gcshape dictionaries.

GO Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

Six months after Go 1.18, a platform team ran a “generics modernization” sprint: forty exported functions converted from interface parameters to type parameters. process(r io.Reader) became process[R io.Reader](r R). The PR was huge, green, and proudly typed. Then the release pipeline flagged it: binary size up 12%, full-build time up roughly a third, and the benchmark suite — flat. Every caller passed *os.File or *bytes.Reader, so every instantiation landed in the same pointer gcshape and dispatched through a dictionary, exactly like the interface call it replaced. Worse, two downstream services broke: the function signatures had changed, and one caller had been storing process in a variable of function type. The team reverted thirty-eight of the forty conversions. The two that survived were the real thing — a typed Set[T comparable] and a generic LRU — both data structures, neither about behaviour. The sprint retro produced one sentence worth keeping: we confused “can be generic” with “should be”.

By the end you will be able to name the smell in a review comment and explain exactly which part of the indirection survived the conversion.

Two different questions: behaviour or data?

Interfaces and generics both abstract, but they answer different questions. An interface answers what can this value do — it erases the concrete type behind a method set, decided at runtime, which is why one []io.Reader can hold a file, a buffer, and a network conn at once. A type parameter answers what type does this code work over — it preserves the concrete type through the whole computation, decided at compile time, which is why Min[T cmp.Ordered] can return the same type it received instead of an any the caller must assert. From those two facts falls out the working rule, straight from the language designers: if the body of your function only calls methods on the value, take an interface. Reach for type parameters when you are building data structures, or when functions must work with slices, maps, and channels of any element type. The signature is often the tell — func Log[T fmt.Stringer](v T) uses T exactly once, so the type parameter preserves nothing anyone can observe; func Dedup[T comparable](xs []T) []T needs the type to flow from input to output, which an interface cannot express without forcing assertions on the caller.

// Behaviour: the body only calls methods — interface is the right tool.
func Log(v fmt.Stringer) { logger.Print(v.String()) }

// Data: the element type must survive from argument to result.
func Dedup[T comparable](xs []T) []T {
	seen := make(map[T]struct{}, len(xs))
	out := xs[:0:0]
	for _, x := range xs {
		if _, ok := seen[x]; !ok {
			seen[x] = struct{}{}
			out = append(out, x)
		}
	}
	return out
}

The canon: what slices, maps, and cmp teach

The standard library’s own generics — slices, maps, cmp (Go 1.21) — are the calibration set for taste. Look at what they have in common: every function is an algorithm over a container’s element type. slices.Contains, slices.SortFunc, maps.Keys, cmp.Ordered — none of them models behaviour; all of them needed interface{} and reflection (or per-type copy-paste) before 1.18. And look at how behaviour enters when an algorithm needs it: slices.SortFunc(s, func(a, b T) int) takes a function value, not a method-bearing constraint. Passing behaviour as an explicit func keeps the constraint minimal (any) and lets callers sort by anything without defining a type. That is the canon in two rules: generics own the container and the element type; behaviour arrives as a function argument or stays behind an interface. When you find yourself writing a constraint with a method set — [T interface{ Validate() error }] — pause and check whether func(t T) error, or a plain interface parameter, says it better.

Quiz

You need a function that accepts any value with a String() string method and writes it to a log. Interface parameter or type parameter — and why?

Anti-patterns a reviewer should name

Generic everything. A function with a single concrete use gets a type parameter “for the future”. Cost today: a harder signature, slower builds, instantiation bookkeeping; benefit: zero until that future arrives, at which point adding the parameter would have been a mechanical change anyway. Write the concrete version first; generalize on the second real caller.

Method-only constraints in argument position. func Render[T fmt.Stringer](v T) — the generic spelling of an interface parameter. It does have one honest niche: when callers pass value types and the call is hot, instantiation can avoid the interface-boxing allocation and devirtualize. That niche is real but narrow — measure first, because for pointer arguments the gcshape dictionary gives the same indirect call as the interface did.

Constraint sprawl. Hand-rolled constraint packages duplicating cmp.Ordered, three-line constraints exported from five packages, unions enumerating types that should have been an interface. Constraints are API; every exported one is a thing users must understand and you must keep stable.

Type parameters callers must always spell out. If every call site reads Parse[Config](data) because inference has no argument to look at, consider whether the design wants a regular argument (Parse(data, &cfg)), which also composes better with existing code.

Together these anti-patterns share a root cause: the generic form was chosen because the syntax was available, not because data needed to flow through the type. Without the second real caller — the one that makes the generalization pay — you inherit all the cost and none of the benefit.

Performance, honestly

The performance argument for generics is regularly overstated in both directions, so here are the mechanics. For value-type instantiations the compiler emits dedicated code: comparisons and calls are direct, inlinable, and the win over an interface{}-based version is real — no boxing allocations, no dynamic dispatch on the hot loop. Sorting machinery rebuilt this way (slices.Sort vs sort.Sort on []int) lands in the “tens of percent to a few x” range, which is why containers and algorithms are the canonical use. For pointer-type instantiations all arguments share one gcshape: calls on the type parameter go through the dictionary — indirect, non-inlinable — which is roughly an interface call by another name. That is why the Hook’s sprint measured flat: converting io.Reader parameters to [R io.Reader] moved the indirection from the interface table to the dictionary without removing it. And the costs are not symmetric: every distinct value shape adds machine code (binary size, instruction cache, build time), which is the 12% the pipeline flagged. The honest summary for a review comment: generics win where value types meet algorithms; they tie or mildly lose where pointers meet behaviour; and they always cost compile time and bytes.

Quiz

A PR converts func process(r io.Reader) to func process[R io.Reader](r R). Production callers pass *os.File and *bytes.Reader. What performance change should review expect?

Recall before you leave
  1. 01
    State the decision rule for interfaces vs generics, and apply it to: a logging sink taking anything with String(), and a Dedup over any comparable element.
  2. 02
    The modernization sprint converted process(r io.Reader) to process[R io.Reader](r R) and measured flat performance with +12% binary size. Explain both numbers mechanically.
Recap

The interfaces-versus-generics decision is not stylistic — the two features abstract different things. Interfaces erase a concrete type behind a method set at runtime: the tool for behaviour, for heterogeneous collections, for letting a function accept anything that can read, stringify, or close. Type parameters preserve a concrete type through a computation at compile time: the tool for data — containers like sets and caches, and algorithms over slices, maps, and channels where the element type must flow from argument to result. The designers’ rule compresses it: body only calls methods → interface; building a data structure or an algorithm over element types → generics. The standard library’s slices, maps, and cmp packages are the canon, and they model the second-order rule too: when an algorithm needs behaviour, it takes a func value (SortFunc), not a method-bearing constraint. The anti-patterns to name in review: generic-everything with one concrete caller; [T fmt.Stringer]-style constraints that are interfaces wearing brackets (legitimate only when a measured hot path saves boxing for value types); constraint sprawl duplicating cmp.Ordered; and APIs whose type parameters can never be inferred. The performance truth has two halves — value-shape instantiations genuinely devirtualize and inline, which is why generic containers and sorts win, while pointer-shape instantiations dispatch through a gcshape dictionary at roughly interface-call cost, which is why the modernization sprint measured flat runtime, a 12% fatter binary, and a third more build time. Generalize on the second real caller, and let what varies — behaviour or data — pick the tool. Now when you see a PR that converts an interface parameter to a type parameter, ask one question first: does the element type need to flow from argument to result? If not, the conversion buys nothing but binary bytes and build time.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.