Go 1.18 was still months away when the design was finalised, but the proposal was public and we were already prototyping. After building several services at the European fintech firm with the experimental toolchain, the pattern of when generics help versus when they don’t was becoming clear.

The answer is not “always use generics” or “avoid them.” It’s more specific than that.

Cases Where Generics Pay Off

1. Type-Safe Result/Option Types

Before generics, every function that could fail returned (T, error) and callers checked for nil. For functions that return “value or nothing” without an error, the pattern was ugly:

1
2
3
4
5
6
7
// Before: returns nil for "not found", but T might not be a pointer type
func Find(items []Item, id string) *Item { ... }

// Caller has to deal with nil:
item := Find(items, id)
if item == nil { ... }
useItem(*item)  // must dereference

With generics, a proper Optional type:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type Option[T any] struct {
    value T
    valid bool
}

func Some[T any](v T) Option[T] { return Option[T]{value: v, valid: true} }
func None[T any]() Option[T]    { return Option[T]{} }

func (o Option[T]) Get() (T, bool) { return o.value, o.valid }

func (o Option[T]) OrElse(fallback T) T {
    if o.valid { return o.value }
    return fallback
}

// Usage:
found := Find[Item](items, id)
item, ok := found.Get()

No pointers for non-pointer types, no nil dereferences, explicit “absent” semantics.

2. Generic Map/Filter/Reduce

The most common complaint about Go before generics: writing the same filter function for every type.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func Filter[T any](items []T, pred func(T) bool) []T {
    var result []T
    for _, item := range items {
        if pred(item) { result = append(result, item) }
    }
    return result
}

func Map[In, Out any](items []In, f func(In) Out) []Out {
    result := make([]Out, len(items))
    for i, item := range items {
        result[i] = f(item)
    }
    return result
}

// Chains:
activeSymbols := Map(
    Filter(positions, func(p Position) bool { return p.Quantity != 0 }),
    func(p Position) string { return p.Symbol },
)

This is genuinely cleaner than either interface{} versions or code generation. The type inference means you usually don’t need to write the type parameters explicitly.

3. Typed Event Bus

Before generics:

1
2
3
4
5
// interface{}-based: no type safety at subscription time
bus.Subscribe("price-update", func(event interface{}) {
    price := event.(PriceEvent)  // runtime assertion, can panic
    ...
})

With generics:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type EventBus[T any] struct {
    mu          sync.RWMutex
    subscribers []func(T)
}

func (b *EventBus[T]) Subscribe(handler func(T)) {
    b.mu.Lock()
    defer b.mu.Unlock()
    b.subscribers = append(b.subscribers, handler)
}

func (b *EventBus[T]) Publish(event T) {
    b.mu.RLock()
    defer b.mu.RUnlock()
    for _, h := range b.subscribers {
        h(event)
    }
}

// Usage:
priceBus := &EventBus[PriceEvent]{}
priceBus.Subscribe(func(p PriceEvent) { ... })  // type-safe
priceBus.Publish(PriceEvent{...})

The subscriber callback is typed — no runtime assertion, compiler catches mismatches.

4. Concurrent-Safe Generic Cache

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type Cache[K comparable, V any] struct {
    mu    sync.RWMutex
    items map[K]V
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.items[key]
    return v, ok
}

func (c *Cache[K, V]) Set(key K, value V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = value
}

Write once, use for Cache[string, Price], Cache[int, User], etc. Previously you’d either use map[string]interface{} (no type safety) or write a separate cache implementation per type.

Cases Where Generics Add Complexity Without Benefit

Forcing generics on simple functions

1
2
3
4
5
// Bad: generics where a specific type is clearer
func ParsePrice[T ~string](s T) (float64, error) { ... }

// Good: just use string
func ParsePrice(s string) (float64, error) { ... }

If a function works with exactly one type in practice, don’t make it generic. The constraint adds noise without enabling anything.

Generic wrappers around interfaces

1
2
3
4
5
// Unnecessary: the interface is already generic enough
type Repository[T any] interface {
    FindByID(id string) (T, error)
    Save(item T) error
}

If you have a single concrete implementation, a plain interface is clearer. Generics for interfaces are only valuable when you have multiple implementations and the generic constraint captures something useful.

Premature abstraction

Generics make it easy to build abstract utility types. The risk: building a generic Pipeline[T] before you have more than one type flowing through a pipeline, or a generic Result[T, E] before you have multiple error types.

Build the concrete version first. Extract the generic version when you have two concrete implementations and can see the pattern clearly. Don’t start with the generic.

The Practical Guidance

Use generics when:

  • You have the same logic implemented multiple times for different types
  • You’re using interface{} with type assertions where the type is known at the call site
  • The function or type genuinely works for all types (or a constrained subset)

Don’t use generics when:

  • There’s one concrete type and no clear future need for others
  • The constraint logic is more complex than the code it saves
  • The team isn’t familiar with the generic type parameter syntax (readability matters)

Go’s generics are conservative by design — they solve the concrete annoyances (typed containers, utility functions) without trying to express complex type-theoretical relationships. That’s the right trade-off for the language’s goals.