Where do type parameters actually replace reflection in a mapping layer, and where can they not?
answer
- constraints are type sets
- types and methods, not fields
- no boxing where T is stored
- generic skeleton, per-type step
- stenciling plus dictionaries, not erasure
basics
~20 sType parameters replace reflection wherever code is generic over a whole value: containers, algorithms, typed caches, all without interface boxing. They cannot enumerate an arbitrary struct's fields, so field-by-field mapping still needs reflection or generated code.
solid answer
~50 sA constraint in Go is a type set — a set of types, plus a method set. It can say "any type with an `Error() string` method" or "any integer", but there is no way to say "any struct with these fields", and a generic function cannot iterate an unknown struct's fields. So the honest split is: everything in a mapping layer that handles a value as a whole — a typed cache, a batch buffer, `Map`/`Filter`/`Keys` helpers, a result slice — becomes generic and loses its `any` boxing, because a `[]T` stores `T` directly rather than an interface. The field-by-field step in the middle does not, and that is where reflection or generated code stays. The pattern that gets the most out of both is a generic skeleton parameterised by a per-type function value: the skeleton is written once and is fully static, and only the small per-type function is reflective or generated.
code
go · 12 linestype Cache[K comparable, V any] struct {
m map[K]V
}
func (c *Cache[K, V]) Get(k K) (V, bool) {
v, ok := c.m[k]
return v, ok
}
func (c *Cache[K, V]) Put(k K, v V) {
c.m[k] = v
}go deeper
Know that a constraint describes which types are allowed and which methods they have, and that generic code cannot look inside an unknown struct at its fields. That boundary is the whole point here.
Be able to explain why []T does not box where []any does, and to state how Go compiles generics — GC-shape stenciling with dictionaries, not erasure and not full monomorphisation.
Demonstrate that you would audit which parts of a mapping layer were only dynamic because of an any in a signature, convert those, and keep reflection confined to the field-by-field step that genuinely needs it.
Own the API consequence: making the entry point generic changes what every calling team writes, and a constraint enumerating supported types turns a local decision into a central list that every model owner must edit.
## What a constraint can and cannot say A type parameter's constraint is an interface used as a **type set**: the set of types permitted as the argument, together with the methods every one of them has. `comparable`, `~int | ~int64`, and `interface{ Key() string }` are all constraints. What no constraint expresses is structure: there is no way to write "any struct with an `ID int` field" and then read `x.ID` from a value of the type parameter. Field access through a type parameter is not something the language offers, and that single limitation decides most of this topic. If your code's job is *to enumerate the fields of a type it has never seen*, generics do not help; reflection and code generation are the only two mechanisms that can. ## How generics are compiled, and why that matters for cost The Go compiler uses **GC-shape stenciling with dictionaries**: it emits one instantiation per memory shape — all pointer-shaped types share one — and passes a dictionary carrying the per-type details that shape does not determine. Two consequences worth stating in an interview: - It is **not type erasure**. Values of type `T` are stored and passed as `T`, not as interfaces, so a generic container does not box, and `allocs/op` drops accordingly. - It is **not full monomorphisation** either. A call to a constraint method may go through the dictionary rather than being a direct static call, so generics are cheaper than reflection but not always identical to hand-written code for one concrete type. Claiming either extreme is the common wrong answer. ## What genuinely converts in a mapping layer Walk a typical row-mapping package and most of it turns out not to be about fields at all: - The container that accumulates results: `[]any` becomes `[]T`, removing one interface conversion and one allocation per row. - A per-type cache or registry: `map[string]any` plus a type assertion becomes `Cache[K, V]`, and the assertion — a run-time check that could fail — disappears at compile time. - Helpers such as "apply this function to every element" or "collect the keys": each was `any`-typed and reflective or assertion-heavy; each becomes a five-line generic function that is fully checked at build time. - The public entry point: `func Load(dst any) error` becomes `func Load[T any]() ([]T, error)`, which is a real API improvement even if the internals stay reflective — callers stop passing pointers to be filled and stop asserting the result. What does not convert is the inner loop that puts column three into field three of an unknown struct. ## The pattern that gets both Separate the skeleton from the per-type step: ``` func Scan[T any](rows *sql.Rows, into func(*T) []any) ([]T, error) ``` The skeleton is generic, static and written once. The `into` function — which hands back the addresses to scan a row into — is per type, and it can be hand-written for the two hot types and reflective for the long tail, or generated for all of them. This is why the three alternatives in this topic are not mutually exclusive: generics shrink the surface that has to be dynamic, and then you only pay for reflection or code generation on what is left. ## Judgment: when to reach for generics first They are the right first move when the dynamism in your code is **not really about structure**. A great deal of code reaches for `reflect` when all it needed was to be generic over a whole value — the `any` in the signature was the only reason reflection appeared at all. That code gets faster, shorter and compile-time-checked in one change, with no build-step consequences. They are the wrong move when someone tries to force structural work through them: an enormous constraint listing every supported struct type, a type switch over `any(x)` inside a generic function, or a per-type interface every model must implement by hand. Each of those reproduces code generation's obligations with none of its automation, and the interface-per-model version quietly makes every future field a change every model owner must make. ## Version note Type parameters have been in the language since Go 1.18, so "we cannot use generics" is rarely a real constraint today. The limitation that persists is structural, not chronological: constraints describe types and methods, and a struct's fields are neither.
- A colleague proposes a constraint listing every model struct the batch supports, so the mapper can be generic. What goes wrong?Nothing becomes readable. A type set restricts which types may be passed; it still gives the body no way to reach a field, so the function ends up type-switching or reflecting anyway. Meanwhile the constraint has become a central list every team must edit to add a model, which is the coupling code generation exists to avoid.
- Does making the result container generic actually reduce allocations, or just tidy the types?It genuinely reduces them. `[]any` stores interface values, so every non-pointer element is boxed on its way in — one heap object per row. `[]T` stores `T` inline, so the boxing disappears entirely and so does the type assertion on the way out. On a ten-million-row batch that is one allocation per row removed for a mechanical change.
- Are Go generics erased at run time like some other languages' generics?No. The compiler uses GC-shape stenciling with dictionaries: one instantiation per memory shape, plus a dictionary for the per-type details. Values are stored as their actual type rather than as interfaces, which is why generic containers do not box. It is also not full monomorphisation, so a constraint-method call may route through the dictionary rather than being a direct static call.
saying these in an interview costs you the question
- Believes a constraint can require a struct field
- Says Go generics are erased at run time
- Says every instantiation is fully monomorphised machine code
- Proposes a constraint listing every supported model struct
- Type switches on any(x) inside a generic function and calls it generic
- Claims generics can replace a struct-walking mapper outright