Day 33 — When not to use generics
Day 33 — When not to use generics
Stage IV · ~3h (theory-heavy)
Goal: Develop judgment—spot needless type parameters, refactor them out, and know when interfaces, any, code generation, or plain duplication is the better design.
Generics are a power tool. Stage IV fails if every function grows [T any]. Expert Go still looks mostly non-generic at application boundaries.
Why this day exists
After Days 31–32, the temptation is:
- Generic repository of generic repository of…
- Public APIs that force callers to write type args everywhere
- Constraints nobody can read
- “Type-safe” wrappers that only ever instantiate one type
This day is the antidote: delete type parameters that don’t earn their complexity. Judgment is a skill you will reuse every time you review a PR.
Theory 1 — When generics are justified
Prefer type parameters when:
- Identical algorithms over many types (containers, slices helpers)
- Compiler-enforced element safety beats runtime asserts
- Zero/allocation patterns shared without interface boxing pain
- You would otherwise generate code or copy nearly identical files
- The abstraction is stable (containers age well; domain workflows less so)
Examples: slices.SortFunc, your Stack[T], graph algorithms over node IDs, small functional helpers like Map/Filter in internal packages.
// Justified: one algorithm, many element types, zero domain coupling.
func First[T any](s []T, pred func(T) bool) (T, bool) {
var zero T
for _, v := range s {
if pred(v) {
return v, true
}
}
return zero, false
}Theory 2 — When generics are not justified
Avoid or remove generics when:
| Smell | Prefer |
|---|---|
| Only one type argument ever used | Concrete type |
Constraint is just any and body uses reflection |
Redesign |
| API is about behavior, not representation | Interface methods |
| Type param only threads through without use | Delete it |
| Call sites become unreadable | Split or concrete |
| Performance unmeasured “because monomorphization” | Measure first |
Domain nouns (Invoice[T], UserService[TRepo]) |
Interfaces + composition |
Interface-first domain models
type Store interface {
Get(ctx context.Context, id string) (Item, error)
Create(ctx context.Context, it Item) error
}Often better than:
type Store[T any] interface {
Get(ctx context.Context, id string) (T, error)
Create(ctx context.Context, v T) error
}unless you truly have many stored entity shapes sharing the same package algorithmically (rare in application code).
The “one instantiation forever” test
If go test and your binary only ever use Store[User], the type parameter is documentation theater. Collapse to UserStore.
Theory 3 — Generics vs any vs assertions
| Tool | Cost | Benefit |
|---|---|---|
| Generics | API complexity, inference noise | Compile-time safety |
any |
Runtime assert risk | Flexibility |
| Interfaces | Small method sets | Polymorphism by behavior |
| Duplication | Lines of code | Clarity for 2 cases |
Two copies can beat one generic if the types diverge next week. Duplication is reversible; a wrong abstraction is sticky.
// Runtime escape hatch — only at boundaries you control.
func asString(v any) (string, error) {
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("want string, got %T", v)
}
return s, nil
}Do not rebuild half of generics with any + panic-on-assert.
Theory 4 — Premature abstraction timeline
- Write concrete code
- Write a second concrete case
- Feel real pain (copy-paste bugs, divergent fixes)
- Abstract (generic or interface)
Jumping to step 4 at step 1 is how libraries rot. The two-case rule is not dogma—it is a default delay that forces evidence.
Day 1: UserRepo with concrete methods
Day 14: ProductRepo looks 90% identical
Day 15: extract shared helpers OR Store[T] if Scan/shape truly match
Theory 5 — Refactoring patterns that remove generics
Collapse single-use param
// before
func Print[T any](v T) { fmt.Println(v) }
// after
func Print(v any) { fmt.Println(v) } // or just fmt.Println at call siteReplace with interface
// before
func SaveAll[T Entity](xs []T) error
// after — if Entity is the behavior you need
func SaveAll(xs []Entity) errorBound at the edges
Keep generics inside a package; export concrete wrappers:
func NewUserStack() *Stack[User] { return &Stack[User]{} }Callers never type-arg. Your public surface stays boring—on purpose.
Delete pass-through params
// SMELL: T never appears in a meaningful constraint or algorithm
func (s *Service[T]) Run(ctx context.Context) error {
return s.inner.Run(ctx)
}
// after: Service without T; compose inner directlyTheory 6 — Readability budget
Every type parameter costs:
- Cognitive load at definition
- Inference failures at call site
- Worse godoc for beginners
- Harder stack traces and IDE hover walls
Budget: if the function is domain business logic (billing, auth, order workflow), generics are rarely first choice. If it is a container/algorithm, generics shine.
// Hard to teach, hard to call
func Pipe[A, B, C any](a A, f func(A) B, g func(B) C) C { return g(f(a)) }
// Clear at the call site
b := f(a)
c := g(b)Worked example — over-generic API
// SMELL: three free type params, no shared package story
func Handle[T any, U any, V any](
ctx context.Context,
in T,
mapFn func(T) (U, error),
reduceFn func(V, U) V,
init V,
) (V, error) {
u, err := mapFn(in)
if err != nil {
return init, err
}
return reduceFn(init, u), nil
}Nobody enjoys calling this. Split steps:
u, err := mapFn(in)
if err != nil {
return err
}
out := reduce(init, u)Or accept []U after a normal loop. Name the domain operations.
Worked example — justified generic
// GOOD: clear container, one concept, obvious methods
type Ring[T any] struct {
buf []T
head int
full bool
}
func NewRing[T any](n int) *Ring[T] {
if n <= 0 {
panic("ring size must be positive")
}
return &Ring[T]{buf: make([]T, n)}
}
func (r *Ring[T]) Push(v T) {
r.buf[r.head] = v
r.head = (r.head + 1) % len(r.buf)
if r.head == 0 {
r.full = true
}
}One concept, one type param, methods that need T. This is the gold standard shape.
Worked example — interface wins for domain
package billing
type Pricer interface {
Price(ctx context.Context, sku string, qty int) (Money, error)
}
type Checkout struct {
prices Pricer
}
func (c *Checkout) Total(ctx context.Context, lines []Line) (Money, error) {
var sum Money
for _, ln := range lines {
p, err := c.prices.Price(ctx, ln.SKU, ln.Qty)
if err != nil {
return Money{}, err
}
sum = sum.Add(p)
}
return sum, nil
}Making Checkout[P Pricer] gains nothing: the method set already expresses variation.
Lab 1 — Find and kill
Workspace: ~/lab/90daysofx/01-go/day33
- Revisit Day 31–32 code (or write a deliberately over-generic module).
- Identify ≥3 type parameters that don’t earn keep.
- Refactor them out.
- Keep tests green.
mkdir -p ~/lab/90daysofx/01-go/day33
cd ~/lab/90daysofx/01-go/day33
go mod init example.com/day33
go test -race ./...Journal: for each removal, one sentence “why it failed the keep test.”
Lab 2 — Two-case rule
Implement sorting of []User by name without generics:
slices.SortFunc(users, func(a, b User) int {
return cmp.Compare(a.Name, b.Name)
})Then add []Product by price. Only if the comparison logic is truly shared beyond the callback, introduce a generic helper—not a giant generic domain type.
Journal the decision: concrete + SortFunc vs SortBy[T].
Lab 3 — Interface vs generic store
Write both:
UserStoreinterface + memory backend
Store[T]generic + memory backend
Implement in a tiny main that does Get/Put for users. Compare:
| Axis | UserStore | Store[T] |
|---|---|---|
| Call-site clarity | ||
| Test ergonomics | ||
Adding Order entity |
Choose a winner for this program and defend it in 5 sentences in NOTES.md.
Lab 4 — Review checklist
Create review-generics.md:
- Each type param named meaningfully (
Tok for pure containers)
- Constraints minimal (no kitchen-sink interfaces)
- No free
anyparams that only pass through
- Exported API readable without a blog post
- Stdlib alternative considered (
slices,maps,cmp)
- At least two real instantiations or concrete collapse
Apply the checklist to Day 31–32 packages; fix findings; re-run tests.
Lab 5 — Readability rewrite
Take one exported function with ≥2 type parameters. Rewrite it as:
- Concrete types, or
- Interface methods, or
- Smaller generic helper used internally only
Commit the before/after as comments or a short before.go.txt snippet in the lab notes.
Common gotchas
| Gotcha | Fix |
|---|---|
| Generic for one type | Concrete |
| Interface + generic redundancy | Pick one axis of variation |
Exporting raw Stack[T] everywhere |
Constructors / aliases |
| Using generics to avoid designing methods | Model behavior with interfaces |
| Copy-paste of Java/C# patterns | Write Go |
~string constraints “just in case” |
Add when a second type needs them |
Public any constraints that panic inside |
Validate at edges; prefer real constraints |
Checkpoint
- Removed needless generics in a real package
- Articulated two-case rule with an example
- Compared interface vs generic store in writing
- Has a reusable review checklist
- Tests still pass under
-race
- Can explain “when not to” without dogma
Commit
git add .
git commit -m "day33: generics judgment — remove needless type params"Write three personal gotchas before continuing.
Tomorrow
Day 34 — Fuzzing: go test -fuzz to attack parsers and find inputs your tables never imagined.