After spending several lessons on what reflection can do, it is worth turning the question around: when should you specifically choose not to use reflection, even when it would work? The answer is almost always “when you have a statically typed alternative,” because statically typed code is faster, catches errors at compile time rather than runtime, and is easier for both humans and tools to reason about.
Go 1.18 added generics, which eliminated one of the most common justifications for reflection: writing algorithms that work on slices, maps, or other containers of unknown element type. Code generation eliminates another: producing type-specific serializers, converters, and mappers that are faster than reflection can ever be. Knowing when to reach for these alternatives instead of reflection is part of writing mature Go.
The Problem
Pre-generics Go had a real gap: the only way to write a Map function that worked on any slice type was reflection. That gap is now closed, but there is still code in many codebases using reflection for problems generics solve better:
// WRONG — using reflection for a problem generics solve
func Contains(slice interface{}, item interface{}) bool {
s := reflect.ValueOf(slice)
if s.Kind() != reflect.Slice {
panic("Contains: first argument must be a slice")
}
for i := 0; i < s.Len(); i++ {
if reflect.DeepEqual(s.Index(i).Interface(), item) {
return true
}
}
return false
}
// Usage — no type safety, panics on wrong input
if Contains(users, targetUser) { ... }
This panics if you pass a non-slice, has no type safety (you can search for a string in a slice of users), and is about 10x slower than a direct loop because of the reflect overhead per element.
The second common pattern is reflection-based deep copying, marshaling, or transformation for types that could be handled with explicit generated code:
// WRONG — generic deep copy via reflection: slow and panic-prone
func DeepCopy(src interface{}) interface{} {
original := reflect.ValueOf(src)
copy := reflect.New(original.Type()).Elem()
copyRecursive(original, copy)
return copy.Interface()
}
The Idiomatic Way
For algorithms that operate on typed collections, generics are the right tool:
// RIGHT — generic Contains: type-safe, fast, no reflection
func Contains[T comparable](slice []T, item T) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
// Type-safe: compiler catches mismatched types
users := []User{...}
if Contains(users, targetUser) { ... } // compiles only if targetUser is User
// Also works for strings, ints, any comparable type
if Contains([]string{"a", "b", "c"}, "b") { ... }
The generic version is compiled to direct comparisons — as fast as handwritten code, fully type-checked, and zero risk of a runtime panic from wrong input kind.
For more complex operations, a constraints package approach:
// Generic filter — works on any slice of any type
func Filter[T any](slice []T, pred func(T) bool) []T {
result := make([]T, 0, len(slice)/2)
for _, v := range slice {
if pred(v) {
result = append(result, v)
}
}
return result
}
// Generic reduce
func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U {
acc := initial
for _, v := range slice {
acc = fn(acc, v)
}
return acc
}
// Usage
activeUsers := Filter(users, func(u User) bool { return u.Active })
totalAge := Reduce(users, 0, func(sum int, u User) int { return sum + u.Age })
These are zero-overhead compared to hand-written loops after inlining. The compiler eliminates the function call boundaries entirely for simple predicates.
For serialization hot paths where encoding/json reflection cost is measurable, code generation produces faster code:
// Code-generated marshaler for User — called instead of reflection-based encoding
// (generated by easyjson, ffjson, or similar tools)
func (u User) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
buf.WriteString(`{"id":`)
buf.WriteString(strconv.Itoa(u.ID))
buf.WriteString(`,"name":`)
buf.WriteString(strconv.Quote(u.Name))
buf.WriteString(`,"email":`)
buf.WriteString(strconv.Quote(u.Email))
buf.WriteByte('}')
return buf.Bytes(), nil
}
This is what easyjson generates. It is faster than encoding/json’s reflection path by 3–10x because it makes direct field accesses instead of using reflect.Value. When you implement json.Marshaler, encoding/json calls it directly rather than reflecting on the struct.
In The Wild
A metrics collection service I worked on used reflection to serialize metric labels — every data point had a struct of labels that was marshaled to a string for the metric key. At moderate throughput this was fine. At 500,000 events per second, the reflection overhead was visible in profiles — about 25% of CPU time in reflect.ValueOf and friends.
We had two candidate solutions: generate marshalers for the ten label types in use, or rewrite the hot path to accept an explicit list of key-value pairs instead of a struct.
The key-value approach was simpler and eliminated reflection entirely:
// BEFORE — reflection on every event
type RequestLabels struct {
Service string `metric:"service"`
Method string `metric:"method"`
StatusCode int `metric:"status_code"`
}
func recordEvent(labels RequestLabels, value float64) {
key := marshalLabelsReflect(labels) // slow
counter.Add(key, value)
}
// AFTER — explicit key-value pairs, zero reflection
type Labels []LabelPair
type LabelPair struct{ Key, Value string }
func recordEvent(labels Labels, value float64) {
key := marshalLabels(labels) // simple string join, no reflection
counter.Add(key, value)
}
// Usage:
recordEvent(Labels{
{"service", "api"},
{"method", "GET"},
{"status_code", "200"},
}, 1)
The key-value version was 15x faster on the hot path. The trade-off was that label names were strings rather than struct fields — no compile-time guarantee that “status_code” was spelled correctly. For this use case, the performance win justified the trade-off.
The Gotchas
Generics do not replace all reflection. Generics handle parametric polymorphism — “this function works on any type T.” Reflection handles structural introspection — “inspect what fields this struct has and what their tags are.” These are different capabilities. encoding/json, validators, and ORM scanners still need reflection because they work with arbitrary struct types they cannot know about at compile time.
comparable constraint limits generics. Generic functions that need to compare elements require T comparable. Not all types are comparable — slices, maps, and functions are not. For those, you need a custom comparison function parameter: func Contains[T any](slice []T, item T, eq func(T, T) bool) bool.
Code generation adds tooling complexity. Generated files need to be regenerated when the source types change. This requires go generate in your build pipeline, a clear convention for where generated files live, and discipline about not editing them manually. The performance gain is real but the maintenance cost is also real — reserve code generation for proven hot paths.
Key Takeaway
Generics and code generation are the two strongest alternatives to reflection in Go’s current ecosystem. Generics win for algorithms over typed collections: slices, maps, trees, optional values — any parametric operation where the element type is uniform. Code generation wins for per-type hot paths where the overhead of reflection is measurable and the types are known. Reflection wins where neither applies: when the type is genuinely unknown at compile time, when you are reading struct field metadata, or when you are building infrastructure that other programmers extend with their own types. The question before reaching for reflect should always be: can generics or code generation do this? If yes, use them. Reflection is the tool of last resort, and it earns that designation.
← Lesson 5: Validation Frameworks | Course Index | Next → Lesson 7: go:generate and Code Generation