Day 13 — Generics: Type Parameters, Structs & Judgment

Updated

July 30, 2025

Day 13 — Generics: Type Parameters, Structs & Judgment

Day 31 — Generics intro

Stage IV · ~3h (theory-heavy)
Goal: Use type parameters and constraints confidently—write generic Map/Filter/Reduce helpers without abusing generics as a personality.

Note

Generics (Go 1.18+) remove boilerplate, not design. Stage IV starts only after Stage III so you do not hide races behind type parameters.

Why this day exists

Before generics, common patterns needed:

  • interface{} / any + type assertions
  • Code generation
  • Copy-paste with types renamed

Those still exist—but for pure algorithms over slices/maps/containers, type parameters are the right tool.


Theory 1 — Type parameters on functions

func Identity[T any](v T) T { return v }

x := Identity[int](3)
y := Identity("hi") // inference
Piece Meaning
[T any] Type parameter list; T constrained by any
Instantiation Identity[int] or inferred from args
any Alias for empty interface—unconstrained

Multiple parameters

func Pair[A, B any](a A, b B) (A, B) { return a, b }

Theory 2 — Constraints

A constraint is an interface limiting allowed type arguments.

func Min[T constraints.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}
import "cmp" // Go 1.21+: cmp.Ordered
// or golang.org/x/exp/constraints historically

Go 1.21+ prefers cmp.Ordered in many examples:

import "cmp"

func Min[T cmp.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

Inline interface constraints

type Number interface {
    ~int | ~int64 | ~float64
}

func Add[T Number](a, b T) T { return a + b }

~ means underlying type: type MyInt int satisfies ~int.


Theory 3 — Approximation (~) and unions

type Stringy interface {
    ~string
}

Union:

type Intish interface {
    ~int | ~int32 | ~int64
}

Methods + types in one interface:

type Stringer interface {
    ~string
    String() string // impossible? actually: type set + methods rules
}

Know the rules at a high level: constraints define a type set. If unsure, keep constraints simple—any, cmp.Ordered, or small unions.


Theory 4 — Inference and explicit instantiation

Map([]int{1, 2}, func(x int) int { return x + 1 })
// compiler infers T, U

Map[int, string](xs, strconv.Itoa) // explicit when needed

Inference fails when arguments don’t pin types—then instantiate explicitly.


Theory 5 — Map / Filter / Reduce (stdlib-shaped)

Go did not put full FP combinators in stdlib early; slices and maps packages grew helpers. Still, writing your own teaches constraints.

func Map[T, U any](in []T, f func(T) U) []U {
    out := make([]U, len(in))
    for i, v := range in {
        out[i] = f(v)
    }
    return out
}

func Filter[T any](in []T, keep func(T) bool) []T {
    var out []T
    for _, v := range in {
        if keep(v) {
            out = append(out, v)
        }
    }
    return out
}

func Reduce[T, U any](in []T, init U, f func(U, T) U) U {
    acc := init
    for _, v := range in {
        acc = f(acc, v)
    }
    return acc
}

Prefer stdlib when available

import "slices"

slices.Sort(xs)
slices.Compact(xs)
// etc.

Don’t reinvent a worse slices package for production—labs reinvent for learning.


Theory 6 — Generic interfaces (preview)

type Repo[T any] interface {
    Get(id string) (T, error)
    Save(T) error
}

Useful; can also over-abstract. Day 32–33 drill judgment.


Theory 7 — What generics do not solve

  • Performance magic (sometimes monomorphization helps; not why you start)
  • Removing all interfaces
  • Cross-package API churn without care
  • Hiding concurrency mistakes

Worked example — keys of a map

func Keys[M ~map[K]V, K comparable, V any](m M) []K {
    out := make([]K, 0, len(m))
    for k := range m {
        out = append(out, k)
    }
    return out
}

comparable is required for map keys.


Worked example — pointer constraint pattern

func Zero[T any]() T {
    var z T
    return z
}

Simple and useful for tests.


Lab 1 — Module + Map/Filter/Reduce

Workspace: ~/lab/90daysofx/01-go/day31

mkdir -p ~/lab/90daysofx/01-go/day31
cd ~/lab/90daysofx/01-go/day31
go mod init example.com/day31

Implement Map, Filter, Reduce with tests:

go test -race ./...

(Race optional here but keep the habit.)


Lab 2 — Min/Max with cmp.Ordered

func Min[T cmp.Ordered](a, b T) T
func Max[T cmp.Ordered](a, b T) T
func Clamp[T cmp.Ordered](v, lo, hi T) T

Test with int, float64, string.


Lab 3 — ~ underlying types

type Celsius float64

Write AddTemp[T ~float64](a, b T) T and show Celsius works.


Lab 4 — Replace interface{} code

Take a small pre-generic style helper (or write one) using any + assert; rewrite with type parameters. Compare error messages and call-site clarity.


Common gotchas

Gotcha Fix
Constraint too tight Start with any, tighten
Constraint too loose Compiler can’t use operators
Fighting inference Explicit [T]
Generic everything Day 33—push back
Ignoring slices/maps Prefer stdlib in real code
Method receivers on generic funcs Methods come Day 32 types

Checkpoint

  • Writes generic functions with inference
  • Uses cmp.Ordered or union constraints
  • Explains ~
  • Ships Map/Filter/Reduce tests
  • Knows generics ≠ free performance
  • go test green

Commit

git add .
git commit -m "day31: generics intro — Map/Filter/Reduce + constraints"

Write three personal gotchas before continuing.


Tomorrow

Day 32 — Generic structs & self-referential constraints: type-parameterized data structures and Go 1.26 self-referential type parameter notes.


Day 32 — Generic structs

Stage IV · ~3h (theory-heavy)
Goal: Design generic types (structs/interfaces) with methods, understand instantiation at the type level, and apply Go 1.26 self-referential type parameter constraints where they clarify APIs.

Note

Generic data structures are where type parameters pay rent—safe stacks, result types, keyed sets—when the element type is truly parametric.

Why this day exists

Function generics help algorithms. Type generics help models:

  • Stack[T]
  • Result[T]
  • Set[T comparable]
  • Graph nodes with self-referential constraints (1.26+)

Without care you invent Java-enterprise type soup. With care you delete unsafe any.


Theory 1 — Generic struct declaration

type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(v T) {
    s.items = append(s.items, v)
}

func (s *Stack[T]) Pop() (T, bool) {
    var zero T
    if len(s.items) == 0 {
        return zero, false
    }
    i := len(s.items) - 1
    v := s.items[i]
    s.items = s.items[:i]
    return v, true
}

Notes:

  • Methods declare [T] on the receiver type name, not a second parameter list of free params (methods cannot introduce new type params in Go).
  • Pointer vs value receiver rules unchanged.
  • Zero value Stack[T]{} is ready to use if fields are.

Theory 2 — Instantiation

var si Stack[int]
ss := Stack[string]{}
sp := &Stack[Person]{}

You cannot have a variable of type Stack without type args—partial types aren’t values.

Type aliases:

type IntStack = Stack[int]

Theory 3 — Constraints on type parameters of types

type Set[T comparable] struct {
    m map[T]struct{}
}

func NewSet[T comparable]() *Set[T] {
    return &Set[T]{m: make(map[T]struct{})}
}

func (s *Set[T]) Add(v T) { s.m[v] = struct{}{} }
func (s *Set[T]) Has(v T) bool { _, ok := s.m[v]; return ok }

comparable is mandatory for map keys.


Theory 4 — Generic interfaces

type Queue[T any] interface {
    Enqueue(T)
    Dequeue() (T, bool)
}

type SliceQueue[T any] struct{ s []T }

func (q *SliceQueue[T]) Enqueue(v T) { q.s = append(q.s, v) }
func (q *SliceQueue[T]) Dequeue() (T, bool) { /* ... */ }

Interface satisfaction remains implicit—at instantiated types.


Theory 5 — Go 1.26 self-referential type parameters

Go 1.26 lifts a restriction: a generic type may refer to itself in its type parameter list/constraints.

type Adder[A Adder[A]] interface {
    Add(A) A
}

func Sum[A Adder[A]](x, y A) A {
    return x.Add(y)
}

Why it matters

Previously, patterns like “type must be able to combine with itself” needed awkward workarounds. Self-referential constraints enable cleaner abstract-algebra-style APIs and some recursive structure definitions.

Practical guidance

  • Use when the constraint truly is self-referential
  • Don’t force every model into Node[N Node[N]] fashion statements
  • Prefer simple any/comparable until a self-ref constraint removes real duplication

Example sketch—ordered merge of like types:

type Merger[T Merger[T]] interface {
    Merge(T) T
}

Theory 6 — Pointer method sets and generics

If constraint requires a method with pointer receiver, the type argument may need to be *T:

type Stringer interface {
    String() string
}

func Print[T Stringer](v T) { fmt.Println(v.String()) }

// If String has pointer receiver on Foo, use Print[Foo] only if value methods include String;
// otherwise Print[*Foo](&foo).

Same method-set rules as non-generic Go.


Theory 7 — Embedding and composition

type Pool[T any] struct {
    Stack[T]
    mu sync.Mutex
}

func (p *Pool[T]) Get() (T, bool) {
    p.mu.Lock()
    defer p.mu.Unlock()
    return p.Pop()
}

Mutex still must not be copied; use pointers to pools.


Worked example — Result[T]

type Result[T any] struct {
    Val T
    Err error
}

func OK[T any](v T) Result[T]     { return Result[T]{Val: v} }
func Err[T any](err error) Result[T] { return Result[T]{Err: err} }

func (r Result[T]) Unwrap() (T, error) {
    return r.Val, r.Err
}

Useful for channel payloads; still idiomatic to use (T, error) at exported sync APIs.


Worked example — linked list node

type Node[T any] struct {
    Val  T
    Next *Node[T]
}

func (n *Node[T]) Len() int {
    c := 0
    for n != nil {
        c++
        n = n.Next
    }
    return c
}

Lab 1 — Generic Stack + tests

Workspace: ~/lab/90daysofx/01-go/day32

mkdir -p ~/lab/90daysofx/01-go/day32 && cd ~/lab/90daysofx/01-go/day32
go mod init example.com/day32

Implement Stack[T] with Push/Pop/Peek/Len. Table tests for int and string.

go test -race ./...

Lab 2 — Set and Map helpers

Set[T comparable] with Add/Remove/Has/Len/Elements.
Optional: MapKeys[K comparable, V any](map[K]V) []K.


Lab 3 — Self-referential constraint (1.26)

On Go 1.26.x:

  1. Define a small interface with self-ref type parameter (like Adder).
  2. Implement for a concrete type (Int with Add).
  3. Write a generic algorithm using the constraint.
  4. Note in journal how this differs from Go ≤1.25 restriction.

If tooling blocks, write the code and comment compile requirements.


Lab 4 — Thread-safe pool

Combine Day 22 mutex with Stack[T] as a free-list pool. Tests under -race with parallel alloc/free.


Common gotchas

Gotcha Fix
Methods adding new type params Not allowed—put on package funcs
Forgetting comparable for map keys Constrain T comparable
Copying mutex in generic struct Pointer receivers; no value copy
Over-abstract self-ref constraints Use only when needed (Day 33)
Exported type with awkward inference Provide constructor NewFoo[T](...)

Checkpoint

  • Built Stack[T] and Set[T]
  • Explains type instantiation
  • Understands methods cannot introduce new type params
  • Tried Go 1.26 self-referential constraint example
  • Race-clean concurrent pool optional lab
  • go test green

Commit

git add .
git commit -m "day32: generic structs — Stack/Set + 1.26 self-ref notes"

Write three personal gotchas before continuing.


Tomorrow

Day 33 — When not to use generics: remove needless type parameters, prefer interfaces/stdlib, and practice judgment under review pressure.


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.