← All posts

Go Generics in Production: Patterns That Actually Work

Go 1.18 shipped generics in March 2022. I have been using them in production code since mid-2022, and after a year of real-world use, I have developed a clear picture of where generics genuinely improve code and where they add accidental complexity. This post is the practical guide I wish I had when generics landed.

What Problem Do Generics Solve?

Before Go 1.18, code that needed to work with multiple types had three options: use interface{} and type-assert at runtime (losing compile-time safety), use code generation (maintenance burden), or copy-paste (duplication). None of these are good. Generics provide a fourth option: parametrised functions and types that work across concrete types while preserving compile-time type safety.

The canonical examples — a typed Stack, a Map function over slices — are often dismissed as toy problems. But real production code has the same patterns: generic result types, typed caches, repository abstractions, event buses. Let me show you the patterns that have actually improved our codebase.

Pattern 1: Generic Result Type

Returning (T, error) tuples is idiomatic Go, but it does not compose well. If you need to transform, map, or chain operations, each step requires unpacking the tuple. A generic Result[T] type makes this composable:

type Result[T any] struct {
    val T
    err error
}

func OK[T any](v T) Result[T]       { return Result[T]{val: v} }
func Err[T any](e error) Result[T]  { return Result[T]{err: e} }

func (r Result[T]) Unwrap() (T, error) { return r.val, r.err }
func (r Result[T]) IsOk() bool         { return r.err == nil }
func (r Result[T]) Value() T           { return r.val }

// Map transforms the value if successful, propagates the error otherwise
func Map[T, U any](r Result[T], fn func(T) U) Result[U] {
    if r.err != nil {
        return Err[U](r.err)
    }
    return OK(fn(r.val))
}

// FlatMap (monadic bind) for chaining operations that can fail
func FlatMap[T, U any](r Result[T], fn func(T) Result[U]) Result[U] {
    if r.err != nil {
        return Err[U](r.err)
    }
    return fn(r.val)
}

// Usage:
result := Map(
    fetchCampaign(ctx, id),
    func(c Campaign) CampaignDTO { return c.ToDTO() },
)
dto, err := result.Unwrap()

Pattern 2: Generic Repository

Every service has repositories for fetching entities from a database. Without generics, this means either duplicating the same Find/List/Delete logic per entity type or implementing it with reflection and interface{}. A generic base repository eliminates the duplication while keeping full compile-time safety:

type Entity interface {
    TableName() string
    ID() int64
}

type Repository[T Entity] struct {
    db  *sql.DB
    log *slog.Logger
}

func (r *Repository[T]) FindByID(ctx context.Context, id int64) (*T, error) {
    var entity T
    query := fmt.Sprintf("SELECT * FROM %s WHERE id = $1", entity.TableName())

    row := r.db.QueryRowContext(ctx, query, id)
    if err := scanEntity(row, &entity); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrNotFound
        }
        return nil, fmt.Errorf("FindByID %d: %w", id, err)
    }
    return &entity, nil
}

func (r *Repository[T]) List(ctx context.Context, filters ...Filter) ([]T, error) {
    var zero T
    query, args := buildQuery(zero.TableName(), filters)

    rows, err := r.db.QueryContext(ctx, query, args...)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var results []T
    for rows.Next() {
        var entity T
        if err := scanEntity(rows, &entity); err != nil {
            return nil, err
        }
        results = append(results, entity)
    }
    return results, rows.Err()
}

// Concrete repositories compose the generic base with entity-specific methods:
type CampaignRepository struct {
    Repository[Campaign]
}

func (r *CampaignRepository) FindByProfileID(ctx context.Context, profileID string) ([]Campaign, error) {
    return r.List(ctx, Where("profile_id", profileID), Where("status", "ENABLED"))
}

Pattern 3: Typed Event Bus

An event bus without generics requires either type assertions at the consumer side or separate bus instances per event type. A generic bus gives you type-safe subscription and publishing:

type Bus[T any] struct {
    mu   sync.RWMutex
    subs []chan T
}

func (b *Bus[T]) Subscribe(bufSize int) <-chan T {
    ch := make(chan T, bufSize)
    b.mu.Lock()
    b.subs = append(b.subs, ch)
    b.mu.Unlock()
    return ch
}

func (b *Bus[T]) Publish(event T) {
    b.mu.RLock()
    defer b.mu.RUnlock()
    for _, sub := range b.subs {
        select {
        case sub <- event:
        default:
            // subscriber is slow; drop the event rather than block
        }
    }
}

// Usage — compiler enforces that only BidUpdates go to this bus:
var bidUpdates Bus[BidUpdateEvent]

go func() {
    for event := range bidUpdates.Subscribe(100) {
        processBidUpdate(event) // event is BidUpdateEvent, no type assertion needed
    }
}()

bidUpdates.Publish(BidUpdateEvent{CampaignID: id, NewBid: 1.50})

Pattern 4: Generic Cache

An in-memory cache typed to its value type prevents accidental mixed storage and eliminates casts:

type Cache[K comparable, V any] struct {
    mu      sync.RWMutex
    entries map[K]cacheEntry[V]
    ttl     time.Duration
}

type cacheEntry[V any] struct {
    value     V
    expiresAt time.Time
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    entry, ok := c.entries[key]
    if !ok || time.Now().After(entry.expiresAt) {
        var zero V
        return zero, false
    }
    return entry.value, true
}

func (c *Cache[K, V]) Set(key K, value V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.entries[key] = cacheEntry[V]{
        value:     value,
        expiresAt: time.Now().Add(c.ttl),
    }
}

// Strongly typed: only Profile values in this cache
var profileCache Cache[string, Profile]

Where NOT to Use Generics

Generics are not always the right tool. I avoid them in these situations:

  • When you only have one or two concrete types: just write the concrete function. The parametrisation overhead is not worth it for two implementations.
  • When the logic actually differs per type: use interfaces. If the method bodies would be different for different type parameters, you need polymorphism (interfaces), not parametricity (generics).
  • When error messages become unreadable: deeply nested generic types produce Go compiler errors that are hard to parse. If you find yourself debugging generic type inference, reconsider the design.
  • In public APIs: generics add complexity for callers. For internal code the tradeoff is often worth it; for packages others will import, prefer concrete types and interfaces.

Type Constraints Beyond any

The constraints package and custom constraint interfaces let you write functions that only work on certain types:

// Only numeric types
type Number interface {
    ~int | ~int64 | ~float64
}

func Sum[T Number](values []T) T {
    var total T
    for _, v := range values { total += v }
    return total
}

// Any type that has an ID() method
type Identifiable interface {
    ID() string
}

func FindByID[T Identifiable](items []T, id string) (T, bool) {
    for _, item := range items {
        if item.ID() == id { return item, true }
    }
    var zero T
    return zero, false
}

Summary

Generics are a genuine improvement to Go when applied selectively to eliminate real duplication. The four patterns that have provided the most value in our codebase: the Result type for composable error handling, the generic repository base for eliminating CRUD boilerplate, the typed event bus for decoupled communication, and the typed cache for preventing mixed-type storage bugs. Use generics when you see yourself writing the same logic with different types — not to add abstraction for its own sake.

Comments