I had three repositories that looked identical.
UserRepository, ProductRepository, OrderRepository:
same structure, same FindByID, same List, same pagination
logic. The only difference was the return type. Three copies of the same code.
When Go 1.18 shipped with generics, I wanted to merge them all. What happened next
taught me when generics are a good idea — and when they make the problem worse.
What generics actually do
Generics let you write code parameterized by types. Where before you had to either
duplicate code for each type or use interface{} with runtime type
assertions, you can now write a single implementation that works for multiple types —
with compile-time checking.
// Before Go 1.18: duplication or interface{}
func ContainsInt(slice []int, val int) bool {
for _, v := range slice { if v == val { return true } }
return false
}
func ContainsString(slice []string, val string) bool {
for _, v := range slice { if v == val { return true } }
return false
}
// With generics: one implementation
func Contains[T comparable](slice []T, val T) bool {
for _, v := range slice {
if v == val {
return true
}
}
return false
}
T comparable is a constraint: T can be any type that
supports ==. The compiler verifies that passed types satisfy the
constraint — no runtime surprises.
Patterns where generics shine
1. Utility functions on slices and maps
This is the cleanest use case. Generic operations on collections, with no business logic:
func Filter[T any](slice []T, predicate func(T) bool) []T {
result := make([]T, 0, len(slice))
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
func Map[T, U any](slice []T, transform func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = transform(v)
}
return result
}
func Keys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
At the call site, it's readable and idiomatic:
activeUsers := Filter(users, func(u User) bool { return u.Active })
emails := Map(users, func(u User) string { return u.Email })
categoryIDs := Keys(categoryMap)
2. Typed data structures
A thread-safe cache, a queue, an optional value — structures you reuse everywhere but want typed:
type Cache[K comparable, V any] struct {
mu sync.RWMutex
items map[K]cacheEntry[V]
ttl time.Duration
}
type cacheEntry[V any] struct {
value V
expires time.Time
}
func NewCache[K comparable, V any](ttl time.Duration) *Cache[K, V] {
return &Cache[K, V]{
items: make(map[K]cacheEntry[V]),
ttl: ttl,
}
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = cacheEntry[V]{value: value, expires: time.Now().Add(c.ttl)}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.items[key]
if !ok || time.Now().After(entry.expires) {
var zero V
return zero, false
}
return entry.value, true
}
Without generics, this cache returns interface{} and forces a type
assertion on every call. With generics, the type is known at compile time:
userCache := NewCache[int, User](5 * time.Minute)
userCache.Set(42, User{Name: "Alice"})
user, ok := userCache.Get(42) // user is a User, not an interface{}
3. Result and Option types
A common pattern to avoid null values or implicit boolean returns:
type Optional[T any] struct {
value T
present bool
}
func Some[T any](v T) Optional[T] { return Optional[T]{value: v, present: true} }
func None[T any]() Optional[T] { return Optional[T]{} }
func (o Optional[T]) Unwrap() (T, bool) { return o.value, o.present }
func (o Optional[T]) OrDefault(def T) T {
if o.present { return o.value }
return def
}
When generics make everything worse
The generic repository trap
Back to my three repositories. The idea seemed good:
type Repository[T any] struct {
db *sql.DB
table string
scan func(*sql.Rows) (T, error)
}
func (r *Repository[T]) FindByID(ctx context.Context, id int) (T, error) {
rows, err := r.db.QueryContext(ctx, "SELECT * FROM "+r.table+" WHERE id = $1", id)
// ...
return r.scan(rows)
}
func (r *Repository[T]) List(ctx context.Context) ([]T, error) {
// ...
}
In practice, it broke immediately. UserRepository needs
FindByEmail. ProductRepository needs
FindByCategory. OrderRepository needs
FindByCustomerWithItems that does a join. The shared part —
FindByID and List — represents 15% of the total code.
The rest is specific. The generic factors out the 15%, complicates the 85%.
// What you end up writing anyway
type UserRepository struct {
base *Repository[User] // the generic part
db *sql.DB // for specific queries
}
// The complexity just moved somewhere else
Overly complex constraints
When generic constraints start looking like this, it's a signal:
// ❌ Too complex — use an interface instead
type Processable[T Entity, R Result, E interface{ error }] interface {
Process(context.Context, T) (R, E)
Validate(T) E
Rollback(context.Context, T) E
}
// ✅ A simple interface does the same job
type Processor interface {
Process(ctx context.Context, input any) (any, error)
}
If your generic constraint needs itself to be defined, or involves more than one type parameter with relationships between them, ordinary interfaces are almost always more readable.
When methods differ by type
Generics don't let you specialize behavior per type at runtime. If the logic
differs between int and string, generics don't help —
you need interfaces or two distinct functions.
The practical decision rule
| Situation | Solution |
|---|---|
| Same algorithm, different types, identical logic | ✅ Generics |
| Reusable data structure (cache, queue, optional) | ✅ Generics |
| Behavior varies by type | ✅ Interfaces |
| Factoring business logic that differs 85% of the time | ❌ Avoid generics |
| Constraint with more than 2 type params | ❌ Probably an interface |
| Need reflection or type switch | ❌ Generics can't help |
The key question: is the logic identical for all types, or just the structure? If it's the logic: generics. If it's the behavior: interfaces. If it's both: compose them.
Conclusion
Go generics are a measured addition to a language that already valued simplicity. They don't replace interfaces — they fill a specific blind spot: algorithms and data structures that work identically for any type.
What took me time to internalize: generics are a tool for eliminating structural code duplication, not for factoring business logic. As soon as logic starts diverging by type, you leave generics territory and enter interfaces territory — which, combined with functional options and solid DI, covers the vast majority of real-world Go architecture cases.