Generic Pitfalls — Professional Level¶
Table of Contents¶
- The professional view
- Anti-pattern 1 — Over-generic public APIs
- Anti-pattern 2 —
Optional[T]everywhere - Anti-pattern 3 — Generic wrappers around concrete API surface
- Anti-pattern 4 — The "polymorphism by type switch" trap
- Anti-pattern 5 — Constraint factory explosion
- Anti-pattern 6 — Generic god types
- Anti-pattern 7 — Overuse of
anyin generic boundaries - Code review heuristics
- Migration playbooks
- Summary
The professional view¶
A professional Go engineer encounters generics in:
- Open-source libraries written by enthusiastic juniors
- Internal monorepos with mixed adoption
- Migration PRs from
interface{}to generic - Code review where the author "made it generic to be reusable"
After reviewing dozens of such codebases, repeating patterns emerge. Each one compiles, passes tests, and is published. Each one is a maintenance liability. The job of a professional reviewer is to recognize them in the diff.
We list seven recurring anti-patterns and the heuristics for catching them.
Anti-pattern 1 — Over-generic public APIs¶
The smell¶
package mylib
func Process[T any, U any, V comparable, F func(T, U) V](f F, t T, u U) V {
return f(t, u)
}
Three type parameters, a function-typed constraint, and a single line of body. The author wanted maximum reusability. The reader gets minimum readability.
Why it is bad¶
- godoc renders this as a wall of square brackets
- IDE autocomplete shows the user a long signature with placeholders
- Type inference often fails on real call sites; users must instantiate manually
- Future API changes are hard because every parameter is part of the contract
The professional fix¶
Concrete, well-named functions. Generics only where they save real duplication:
func ProcessIntPair(f func(int, int) int, a, b int) int { return f(a, b) }
func ProcessStringPair(f func(string, string) bool, a, b string) bool { return f(a, b) }
Or, if there is genuine reuse, one type parameter:
A senior reviewer asks: "Could this be written with one type parameter, or zero?" Most over-generic APIs collapse under that question.
Anti-pattern 2 — Optional[T] everywhere¶
The smell¶
type Optional[T any] struct {
value T
has bool
}
func Some[T any](v T) Optional[T] { return Optional[T]{value: v, has: true} }
func None[T any]() Optional[T] { return Optional[T]{has: false} }
func (o Optional[T]) Get() (T, bool) { return o.value, o.has }
Imported from Rust or Scala. The author thought "Go's nilable types are gross; let me give us proper Maybe semantics."
Why it is bad¶
- Go has the idiomatic
(T, bool)return;Optional[T]competes with it - Every API boundary forces conversion:
Optional[T]to(T, bool)and back - Library users hate it because the signature is non-standard
- Pointer types (
*T) already carry "may be absent" without a wrapper
The professional fix¶
Use Go's existing idioms:
| Need | Idiomatic Go |
|---|---|
| Maybe a value | (T, bool) |
| Maybe a pointer | *T (nil = absent) |
| Typed result with error | (T, error) |
| Eager null-coalescing | cmp.Or (1.22+) or simple if |
If you really want Optional[T] because your team comes from a functional language, localize it to one package and provide adapters at the boundary:
// internal/option
func From[T any](v T, ok bool) Option[T] { ... }
func (o Option[T]) Tuple() (T, bool) { ... }
Do not pollute every public function signature.
Anti-pattern 3 — Generic wrappers around concrete API surface¶
The smell¶
type GenericLogger[T any] struct {
inner *log.Logger
}
func (l *GenericLogger[T]) Log(v T) { l.inner.Println(v) }
The wrapper accepts any T and forwards to a concrete logger. The type parameter gives the caller a typed surface — but the inside is exactly interface{} with extra steps.
Why it is bad¶
- The type parameter does not constrain anything;
Tcould be anything - The boxing happens inside
Println; the generic does not save allocations - Library users see
GenericLogger[*MyType]everywhere instead of*log.Logger - Adds no real value over a normal logger
The professional fix¶
Either the wrapper actually does something type-specific (then keep it), or it does not (then delete it).
// Either: do something with the type
type TypedLogger[T fmt.Stringer] struct{ ... }
func (l *TypedLogger[T]) Log(v T) {
l.inner.Printf("[%s] %s", v.Type(), v.String())
}
// Or: just use the original
type Logger = *log.Logger
A reviewer flags any generic wrapper whose body is inner.Foo(v) for arbitrary T.
Anti-pattern 4 — The "polymorphism by type switch" trap¶
The smell¶
func Encode[T any](v T) []byte {
switch x := any(v).(type) {
case string:
return []byte(x)
case int:
return []byte(strconv.Itoa(x))
case fmt.Stringer:
return []byte(x.String())
}
panic("unsupported type")
}
Generic syntax masking interface-style polymorphism. The compile-time type parameter T is replaced at runtime by a switch — so the generics do nothing useful.
Why it is bad¶
- The compiler cannot help when a caller adds a new type
- Adding a case requires editing the function (open/closed violated)
panic("unsupported")ships unsafe defaults- Performance is no better than
func Encode(v interface{}) []byte
The professional fix¶
Use a real interface:
Each type implements Encode differently. Adding a new type is a new method, not a code edit. Compiler enforces the contract.
If you want both — concrete primitives and user types — combine:
type Encoder interface { Encode() []byte }
func Encode(v any) []byte {
if e, ok := v.(Encoder); ok { return e.Encode() }
// primitive fast paths
switch x := v.(type) {
case string: return []byte(x)
case int: return []byte(strconv.Itoa(x))
}
panic("unsupported type")
}
Note this version is not generic. The "generic" version was hiding interface dispatch behind generic syntax.
Anti-pattern 5 — Constraint factory explosion¶
The smell¶
package constraints
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
type Float interface { ~float32 | ~float64 }
type Number interface { Integer | Float }
type Ordered interface { Number | ~string }
type Equatable[T any] interface { Equal(T) bool }
type Cloneable[T any] interface { Clone() T }
type Mergeable[T any] interface { Merge(T) T }
type SerializableJSON interface { json.Marshaler; json.Unmarshaler }
// ... thirty more constraints
Every internal helper grows its own one-off constraint. Six months later the package has 30 constraints, half of them satisfied by zero types.
Why it is bad¶
- godoc bloat
- Each constraint imposes a small cognitive tax
- Many overlap or duplicate stdlib
cmp.Ordered - The half that are satisfied by zero types are dead code
The professional fix¶
Adopt a constraint inventory rule:
- Use
cmp.Orderedfrom stdlib first. - Use
comparablefrom the language second. - Use a custom constraint only when at least three call sites need it.
- Constraints with method requirements live in the same package as the helpers that use them.
Audit constraints quarterly. Delete unused ones.
Anti-pattern 6 — Generic god types¶
The smell¶
type Pipeline[T, U, V, W any] struct {
transform1 func(T) U
transform2 func(U) V
transform3 func(V) W
onError func(error)
metrics Metrics
tracer Tracer
}
Five type parameters. Three transforms hard-coded. The author wanted a "fully generic pipeline".
Why it is bad¶
- Inference is impossible at the call site
- Adding a fourth transform requires a new type with six type parameters
- The signature dominates godoc
- Each instantiation costs build time
The professional fix¶
Compose simple binary pipelines:
type Step[T, U any] func(T) U
func Chain[T, U, V any](a Step[T, U], b Step[U, V]) Step[T, V] {
return func(t T) V { return b(a(t)) }
}
Two type parameters per step. Composition extends naturally:
Each Chain call has manageable inference. The user can build arbitrarily long pipelines without ever having a five-parameter type.
Anti-pattern 7 — Overuse of any in generic boundaries¶
The smell¶
The function takes a typed T, returns any. The caller has to cast on the way out. The T parameter does nothing useful; it is decoration.
Why it is bad¶
- Defeats the purpose of generics
- Caller sees
anyand must assert - Compile-time safety is lost
- Reads as "I gave up on generics"
The professional fix¶
Either commit to generics or don't:
// Committed
func Process[T any, R any](v T, fn func(T) R) R { return fn(v) }
// Not committed — fine, just don't pretend
func Process(v any) any { return doSomething(v) }
A reviewer asks: "What is T doing here?" If the answer is "just decorating the parameter", remove the generic.
Code review heuristics¶
A short checklist for reviewing generic Go code:
Heuristic 1 — Type parameter density¶
Count type parameters per signature: - 0 — fine - 1 — fine - 2 — usually fine (K, V) - 3+ — challenge it
Heuristic 2 — Body uses each parameter¶
Verify that every type parameter appears in the body in a way that uses its constraint. If T only flows through, the parameter is decorative.
Heuristic 3 — Constraint matches operations¶
Read the body. List every operation on T (==, <, +, method calls). Verify the constraint exactly allows those — no more, no less.
Heuristic 4 — Public vs internal¶
Generic public APIs are expensive to change. Generic internal helpers are cheap. Reviewers should be more permissive in internal/ and stricter at the package boundary.
Heuristic 5 — Type switch on T¶
Any switch any(v).(type) is a yellow flag. Ask: would an interface be cleaner? Often yes.
Heuristic 6 — Method on *T vs T¶
If the constraint requires a method, verify which receiver type the user expects to satisfy it. Document explicitly.
Heuristic 7 — Zero-value handling¶
Search for var zero T or *new(T). Verify each return-of-zero corresponds to a real "empty" semantics, not a bug.
Heuristic 8 — Reflection inside generic body¶
Reflection inside a generic function is a yellow flag. Ask: would interface{} be more honest?
Heuristic 9 — Cross-package ownership¶
If a generic is defined in one package and instantiated in many others, ask whether the build-cache impact is acceptable. Most of the time yes; for hot CI paths, possibly no.
Heuristic 10 — Constraint package size¶
Watch for constraint files that grow unboundedly. Apply the "rule of three" before adding a constraint.
Migration playbooks¶
Playbook A — Migrating interface{} helpers to generic¶
- Identify the helper (
func Contains(s []interface{}, target interface{}) bool). - Add a generic equivalent alongside with a new name (
ContainsT). - Mark the old one
// Deprecated:. - Migrate callers as they are touched.
- After a major version bump, remove the old.
Avoid silently changing behaviour. The new and old must agree on edge cases (empty slice, nil target) before deprecation.
Playbook B — Tightening a constraint¶
- Audit current callers to see which type arguments they use.
- Verify every existing caller satisfies the tighter constraint.
- Tighten in a major version bump.
- Document in the changelog as a breaking change even if no caller actually breaks.
Tightening looks safe. It is not. Future callers might have used the loose form.
Playbook C — Loosening a constraint¶
- Verify every operation in the body still compiles under the looser constraint.
- Add new tests with types that satisfy the new but not the old constraint.
- Loosening is usually backwards-compatible, but be aware of inference shifts.
Playbook D — Removing a generic helper¶
- Verify no public callers exist.
- If public callers exist, deprecate first; remove only after a deprecation cycle.
- Even internal helpers should be removed in a separate PR for git-bisect friendliness.
Summary¶
A professional Go reviewer reads generic code with a different lens than non-generic code. The seven anti-patterns above account for the majority of "this looked good but aged poorly" cases:
- Over-generic public APIs
Optional[T]everywhere- Generic wrappers without value
- Polymorphism by type switch
- Constraint factory explosion
- Generic god types
anyat generic boundaries
The ten review heuristics provide a checklist that fits in your head. The four migration playbooks cover the most common evolution paths. Generic Go code is mostly fine — the standard library's slices, maps, and cmp packages are the canonical models — but the long tail of community code is dotted with these traps.
A professional engineer does not prevent generics from being used. They prevent generics from being used inappropriately. The next file digs into the spec excerpts that explain why each of these patterns is the way it is.