Go Generics: When to Use Them, When to Avoid Them

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.

Comments (0)