Quite a bit—but selectively.
The initial excitement around Go 1.18 led to a lot of experimentation. Over time, the community has settled into a fairly pragmatic style: generics are excellent for reusable data structures and algorithms, but they're not a replacement for interfaces or simple duplication when the abstraction isn't buying much.
Here are the patterns that have aged well.
Patterns I like
1. Generic collections and data structures
This is the clearest win.
type Set[T comparable] map[T]struct{}
func (s Set[T]) Add(v T) {
s[v] = struct{}{}
}
func (s Set[T]) Contains(v T) bool {
_, ok := s[v]
return ok
}
You get type safety without needing interface{} or code generation.
2. Generic utility functions
Things like Map, Filter, Reduce, Contains, Min, Max, Clamp, etc.
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
}
These eliminate repetitive loops while staying easy to read.
3. Type-safe caches
type Cache[K comparable, V any] struct {
items map[K]V
}
This is much nicer than map[string]interface{}.
4. Generic synchronization wrappers
type AtomicValue[T any] struct {
// ...
}
or
type Future[T any] struct {
// ...
}
Concurrency primitives benefit from compile-time typing.
5. Generic helper libraries
Many libraries now expose APIs like
func Decode[T any](r io.Reader) (T, error)
instead of
func Decode(r io.Reader, v any) error
The call site becomes
user, err := Decode[User](r)
instead of creating a zero value first.
Patterns I avoid
1. Generic business logic
This is probably the biggest mistake people make.
Instead of
func Process[T Customer | Vendor | Employee](x T)
it's often clearer to write
func ProcessCustomer(...)
func ProcessVendor(...)
Business rules usually diverge over time.
2. Deep constraint hierarchies
For example:
type Numeric interface {
Signed | Unsigned | Float
}
and then building layers of constraints on top.
Once constraints become their own mini type system, readability suffers.
3. Java/C++-style generic everything
Code like
type Repository[T Entity, ID comparable] interface {
Find(ID) (T, error)
Save(T) error
}
often looks elegant at first.
But in real applications, repositories usually need entity-specific queries:
FindByEmail(...)
FindActive(...)
FindExpired(...)
Trying to force everything through a generic CRUD interface can make the design less expressive.
4. Generic wrappers around every interface
Sometimes people write
type Handler[T any] interface {
Handle(T) error
}
even when only one concrete type will ever exist.
That adds complexity without much benefit.
5. Reflection plus generics
If the implementation immediately does
reflect.TypeOf((*T)(nil))
or
any(v).(...)
it's worth asking whether generics are really helping. Often you're paying for both abstraction mechanisms at once.
A useful rule of thumb
A good heuristic is:
- Use interfaces to describe behavior ("can read", "can write", "can close").
- Use generics to abstract over data types ("works for any
[]T", "any comparable key", "any value type").
- Use concrete types when there's only one meaningful implementation.
That division tends to produce code that's both idiomatic and maintainable.
Where generics have become commonplace
Today, it's fairly common to see generics used for:
- Collections (
Set[T], queues, heaps, ring buffers)
- Generic caches
- Functional slice helpers
- Concurrent utilities (futures, pools, channels)
- Generic test helpers
- Optional/value wrappers (
Option[T], Result[T] in some codebases)
- Serialization and decoding helpers
They're much less common for:
- HTTP handlers
- Service layers
- Domain models
- Most application business logic
- Database repositories with rich query behavior
The overall trend in mature Go codebases has been "use generics where they remove obvious duplication while keeping the API simple." If a generic abstraction makes the code harder to explain than two or three concrete implementations, many Go developers would choose the concrete implementations instead.