Go 1.18 shipped generics in March 2022. The community's reaction ranged from cautious optimism to outright skepticism. Two years of production code later, the picture is clearer: generics solve a handful of specific problems well and introduce complexity everywhere else. Here is what actually made it into production codebases—and what should stay out.
What Actually Works: Generic Collections
Before generics, you either wrote type-specific implementations or used interface{} (now any) and paid a casting tax everywhere. Generic containers eliminate this tradeoff entirely:
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
func (s *Stack[T]) Len() int {
return len(s.items)
}
This compiles to efficient code with no runtime allocation overhead from boxing. We use this pattern for work queues, AST-like tree structures, and processing pipelines where the element type varies per service but the container logic is identical.
Functional Helpers That Actually Belong in Your Codebase
The slices package (Go 1.21) handled a lot of this, but for domain-specific transformations, generic helpers cut boilerplate without code generation:
func Map[T, U any](items []T, fn func(T) U) []U {
result := make([]U, len(items))
for i, item := range items {
result[i] = fn(item)
}
return result
}
func Filter[T any](items []T, predicate func(T) bool) []T {
var result []T
for _, item := range items {
if predicate(item) {
result = append(result, item)
}
}
return result
}
// Real usage
userIDs := Map(users, func(u User) int64 { return u.ID })
active := Filter(users, func(u User) bool { return u.Active })
Before Go 1.18, this required either go generate templates or []interface{} casts that the compiler could not check. Now it is type-safe and readable.
A Generic Retry That Earned Its Place in Production
This is the generic pattern that removed the most code from our codebase. We had three nearly identical retry wrappers for different return types. This replaced all three:
func Retry[T any](ctx context.Context, maxAttempts int, fn func() (T, error)) (T, error) {
var (
result T
err error
)
for i := 0; i < maxAttempts; i++ {
if ctx.Err() != nil {
return result, ctx.Err()
}
result, err = fn()
if err == nil {
return result, nil
}
backoff := time.Duration(1<<uint(i)) * 100 * time.Millisecond
select {
case <-ctx.Done():
return result, ctx.Err()
case <-time.After(backoff):
}
}
return result, fmt.Errorf("after %d attempts: %w", maxAttempts, err)
}
// Usage
user, err := Retry(ctx, 3, func() (*User, error) {
return repo.FindByID(ctx, userID)
})
The zero-value initialization (var result T) works cleanly for pointer types, slices, and structs alike. Context cancellation is respected at every step.
Repository Patterns: Less Boilerplate, Real Savings
If you build CRUD-heavy services, you write the same repository interface boilerplate repeatedly. Generics make a real dent here:
type Repository[T any, ID comparable] interface {
FindByID(ctx context.Context, id ID) (*T, error)
FindAll(ctx context.Context) ([]T, error)
Save(ctx context.Context, entity *T) error
Delete(ctx context.Context, id ID) error
}
The shared pagination logic, soft-delete, and upsert patterns move into a base implementation. Concrete types only implement the scan function for their specific columns. We reduced repository boilerplate by roughly 40% across a mid-sized service mesh.
What Went Wrong
Over-generalization is a real trap
The first weeks after enabling generics in a codebase, there is a temptation to generalize everything. We refactored a readable HTTP handler pipeline into a Pipeline[Input, Output any] abstraction. It took 200 lines to express what 60 concrete lines did before, and the compiler error messages were incomprehensible.
The rule we now follow: if you do not have two distinct concrete types that need the same logic today—not hypothetically—generics are not justified.
Complex type constraints become unreadable fast
Go's union constraints work fine for numeric types:
type Numeric interface {
~int | ~int32 | ~int64 | ~float32 | ~float64
}
func Sum[T Numeric](items []T) T {
var total T
for _, v := range items {
total += v
}
return total
}
But the moment you need to constrain behavior rather than representation, you are better served by a plain interface with methods.
Stack traces in generic code are verbose
When something fails at runtime inside a generic function, the stack trace shows the instantiated type name: Retry[*github.com/myorg/myapp/domain.User]. It is accurate but noisy. Short type aliases in packages where you instantiate generics most help significantly.
The Verdict
Go generics remove one specific category of duplication: typed container and algorithm code. They do not replace interfaces, embedding, or code generation for other purposes, and they were never meant to.
Use generics for: collection types, typed utilities (Map/Filter/Reduce/Retry), and shared service layer contracts where two or more concrete types share identical behavior today.
Avoid generics for: request/response pipelines, middleware chains, or any abstraction that adds more lines than it removes.
If you are adopting generics in an existing codebase, start with your utility packages. Look for interface{} casts—those are the highest-signal targets. For teams building security-sensitive Go services, pairing these refactors with a thorough security hardening checklist helps surface edge cases that type systems alone cannot catch.
The Takeaway
Two years in, Go generics have settled into a narrow but genuinely useful role. The standard library adopted them thoughtfully (slices, maps, cmp). Production codebases that adopted them selectively are cleaner. The ones that went generic-first are harder to debug.
Write less code, not more abstract code.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)