Day 33 — When not to use generics

Updated

July 30, 2026

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.

Important

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:

  1. Identical algorithms over many types (containers, slices helpers)
  2. Compiler-enforced element safety beats runtime asserts
  3. Zero/allocation patterns shared without interface boxing pain
  4. You would otherwise generate code or copy nearly identical files
  5. 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

  1. Write concrete code
  2. Write a second concrete case
  3. Feel real pain (copy-paste bugs, divergent fixes)
  4. 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 site

Replace with interface

// before
func SaveAll[T Entity](xs []T) error
// after — if Entity is the behavior you need
func SaveAll(xs []Entity) error

Bound 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 directly

Theory 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

  1. Revisit Day 31–32 code (or write a deliberately over-generic module).
  2. Identify ≥3 type parameters that don’t earn keep.
  3. Refactor them out.
  4. 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:

  1. UserStore interface + memory backend
  2. 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 (T ok for pure containers)
  • Constraints minimal (no kitchen-sink interfaces)
  • No free any params 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:

  1. Concrete types, or
  2. Interface methods, or
  3. 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.