Go 1.18 shipped generics in March 2022. After two-plus years of real production code, the initial hype has settled into something more useful: a clear picture of where generics actually save work and where they just add indirection.
Where generics genuinely pay off
Most Go code doesn't need generics. The language worked fine without them for over a decade. But there are concrete patterns where they remove real pain.
Typed data structures. Before generics, implementing a stack, queue, or ring buffer meant either interface{} (losing type safety) or code generation (painful to maintain). Now:
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
}
last := len(s.items) - 1
item := s.items[last]
s.items = s.items[:last]
return item, true
}
func (s *Stack[T]) Peek() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
return s.items[len(s.items)-1], true
}
No reflection, no type assertions at call sites, no generated boilerplate to keep in sync. One definition works for Stack[int], Stack[*http.Request], or Stack[Event].
Functional slice helpers. The standard library's slices package (Go 1.21+) is the canonical example. For anything the stdlib doesn't cover:
func Map[T, U any](s []T, fn func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = fn(v)
}
return result
}
func Filter[T any](s []T, fn func(T) bool) []T {
var result []T
for _, v := range s {
if fn(v) {
result = append(result, v)
}
}
return result
}
func Reduce[T, U any](s []T, initial U, fn func(U, T) U) U {
acc := initial
for _, v := range s {
acc = fn(acc, v)
}
return acc
}
Before generics, filtering a []User meant writing FilterUsers, or accepting a []interface{} and casting. Now one function handles every slice type.
A production use case: typed in-memory cache
Here's a pattern I use in several services — a typed cache with TTL that replaces an untyped map[string]interface{}:
import (
"sync"
"time"
)
type entry[V any] struct {
value V
expiresAt time.Time
}
type Cache[K comparable, V any] struct {
mu sync.RWMutex
entries map[K]entry[V]
ttl time.Duration
}
func NewCache[K comparable, V any](ttl time.Duration) *Cache[K, V] {
return &Cache[K, V]{
entries: make(map[K]entry[V]),
ttl: ttl,
}
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[key] = entry[V]{value: value, expiresAt: time.Now().Add(c.ttl)}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.entries[key]
if !ok || time.Now().After(e.expiresAt) {
var zero V
return zero, false
}
return e.value, true
}
func (c *Cache[K, V]) Delete(key K) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, key)
}
Usage is clean and fully type-safe:
userCache := NewCache[int64, User](5 * time.Minute)
userCache.Set(42, User{ID: 42, Name: "Alice"})
user, ok := userCache.Get(42)
// user is typed as User — no type assertion needed
Before generics, you either wrote a dedicated UserCache struct (duplicated for every type) or used interface{} everywhere and accepted the runtime cast failures. The generic version is a genuine improvement.
What doesn't work as expected
Type constraints are stricter than they first appear. You can't call arbitrary methods on a constrained type unless those methods are part of the constraint definition:
// Works — fmt.Stringer is the constraint
func Stringify[T fmt.Stringer](items []T) []string {
return Map(items, func(t T) string { return t.String() })
}
// Does NOT compile — DoSomething is not defined on `any`
func Process[T any](items []T) {
for _, item := range items {
item.DoSomething() // compile error
}
}
If you need method dispatch, define an explicit interface and use it as the constraint. This is the right mental model: constraints are interfaces, not magic.
Type inference isn't universal. Go infers type parameters in most cases, but not when the return type is the only clue or when there's ambiguity. You'll occasionally need explicit type arguments, which reduces ergonomics:
// inference works here
lengths := Map(names, func(s string) int { return len(s) })
// explicit required when the compiler can't infer from arguments alone
result := SomeGenericFunc[MyType](args...)
Compile time increases. Codebases heavy with generics compile noticeably slower. The stenciling approach Go uses (generating concrete implementations per instantiation) trades compile time for runtime performance. In CI, this can add up.
Patterns to avoid
Don't genericize concrete functions. If a function has one type in practice and that's unlikely to change, keep it concrete. Generic functions are harder to read in error messages and add cognitive overhead during reviews.
Avoid deeply nested type parameters. If you're writing Pipeline[Input any, Intermediate any, Output any] and it's getting hard to track what flows where, a struct with named fields is almost always clearer.
Don't use generics to avoid writing interfaces. This is the most common misuse. Interfaces express behavior; generics express shape. If you want polymorphism through method dispatch, use an interface. If you want to write one function that works on many types without a shared interface, use generics.
The honest verdict
Two years in, Go generics solve a real but narrow set of problems:
- Typed collections (stacks, queues, sets, caches)
- Functional utilities over slices and maps
- Result and option types for error handling patterns
They haven't changed how the majority of Go code looks, and that's fine. The language's interface system still handles most polymorphism cleanly.
The practical heuristic: reach for generics when you find yourself copying the same logic for different concrete types and an interface won't cleanly model what you need. For security-critical code — input parsers, permission checks, audit trail builders — a typed generic result type reduces bug surface compared to interface{} slicing. You can find this and other hardening patterns in our free security checklists.
Everything else: keep it concrete, keep it readable.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)