Skip to content
Codeloom
Go

Real-World Go Generics Examples Beyond Tutorials

Practical Go generics patterns for production code: type-safe collections, result types, repository layers, and functional helpers.

·7 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Type-safe generic data structures for production use
  • Result and Option types that eliminate nil-pointer bugs
  • Generic repository patterns for database access layers

Prerequisites

  • Basic Go knowledge
  • Understanding of interfaces and structs

Go generics landed in 1.18 and most tutorials stop at “here is a generic Max function.” This guide skips the toy examples and shows patterns you will actually use in production codebases.

Type Constraints Refresher

Before diving in, here is the vocabulary. A constraint is an interface that restricts which types a generic function or struct accepts.

// Built-in constraints from the constraints package
import "golang.org/x/exp/constraints"

// Or define your own
type Number interface {
    ~int | ~int64 | ~float64
}

The ~ prefix means “any type whose underlying type is int,” so custom types like type UserID int also qualify.

Pattern 1: Type-Safe Set

Maps in Go make natural sets, but you repeat the boilerplate every time. A generic set eliminates that.

type Set[T comparable] struct {
    items map[T]struct{}
}

func NewSet[T comparable](values ...T) *Set[T] {
    s := &Set[T]{items: make(map[T]struct{}, len(values))}
    for _, v := range values {
        s.items[v] = struct{}{}
    }
    return s
}

func (s *Set[T]) Add(v T) { s.items[v] = struct{}{} }

func (s *Set[T]) Contains(v T) bool {
    _, ok := s.items[v]
    return ok
}

func (s *Set[T]) Remove(v T) { delete(s.items, v) }

func (s *Set[T]) Len() int { return len(s.items) }

func (s *Set[T]) Intersection(other *Set[T]) *Set[T] {
    result := NewSet[T]()
    for v := range s.items {
        if other.Contains(v) {
            result.Add(v)
        }
    }
    return result
}

Usage is clean and type-safe:

tags := NewSet("go", "generics", "tutorial")
tags.Add("production")
fmt.Println(tags.Contains("go")) // true

ids := NewSet(1, 2, 3)
// ids.Add("oops") // compile error: string does not satisfy comparable constraint with int

Pattern 2: Result Type

Go’s multi-return (T, error) convention works well, but it does not compose. A generic Result type enables chaining.

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

func Ok[T any](v T) Result[T] {
    return Result[T]{value: v}
}

func Err[T any](err error) Result[T] {
    return Result[T]{err: err}
}

func (r Result[T]) Unwrap() (T, error) {
    return r.value, r.err
}

func (r Result[T]) IsOk() bool {
    return r.err == nil
}

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.value))
}

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.value)
}

Now you can chain transformations without nested if err != nil blocks:

result := FlatMap(
    fetchUser(ctx, userID),
    func(u User) Result[Order] {
        return fetchLatestOrder(ctx, u.ID)
    },
)

order, err := result.Unwrap()

Pattern 3: Generic Repository

Most services have multiple database entities with identical CRUD logic. A generic repository avoids duplicating that.

type Entity interface {
    TableName() string
    GetID() string
}

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

func NewRepository[T Entity](db *sql.DB) *Repository[T] {
    return &Repository[T]{db: db}
}

func (r *Repository[T]) FindByID(ctx context.Context, id string) (*T, error) {
    var entity T
    table := entity.TableName()

    query := fmt.Sprintf("SELECT * FROM %s WHERE id = $1", table)
    row := r.db.QueryRowContext(ctx, query, id)

    if err := row.Scan(&entity); err != nil {
        return nil, fmt.Errorf("find %s by id %s: %w", table, id, err)
    }
    return &entity, nil
}

func (r *Repository[T]) Delete(ctx context.Context, id string) error {
    var entity T
    table := entity.TableName()

    query := fmt.Sprintf("DELETE FROM %s WHERE id = $1", table)
    _, err := r.db.ExecContext(ctx, query, id)
    if err != nil {
        return fmt.Errorf("delete from %s: %w", table, err)
    }
    return nil
}

Concrete entities implement the constraint:

type User struct {
    ID    string
    Name  string
    Email string
}

func (u User) TableName() string { return "users" }
func (u User) GetID() string     { return u.ID }

// One line to get a full repository
userRepo := NewRepository[User](db)
user, err := userRepo.FindByID(ctx, "u-123")

Pattern 4: Functional Helpers

Generic Map, Filter, and Reduce work on any slice type.

func MapSlice[T, U any](items []T, fn func(T) U) []U {
    result := make([]U, len(items))
    for i, v := range items {
        result[i] = fn(v)
    }
    return result
}

func Filter[T any](items []T, predicate func(T) bool) []T {
    var result []T
    for _, v := range items {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

func Reduce[T, U any](items []T, initial U, fn func(U, T) U) U {
    acc := initial
    for _, v := range items {
        acc = fn(acc, v)
    }
    return acc
}

In practice:

users := []User{{Name: "Alice", Age: 30}, {Name: "Bob", Age: 17}, {Name: "Carol", Age: 25}}

names := MapSlice(users, func(u User) string { return u.Name })
// ["Alice", "Bob", "Carol"]

adults := Filter(users, func(u User) bool { return u.Age >= 18 })
// [Alice, Carol]

totalAge := Reduce(users, 0, func(sum int, u User) int { return sum + u.Age })
// 72

Pattern 5: Concurrent Fan-Out with Generics

Running the same operation on many inputs concurrently is a common pattern. Generics make it reusable.

func FanOut[T, U any](ctx context.Context, items []T, workers int, fn func(context.Context, T) (U, error)) ([]U, error) {
    type indexed struct {
        index int
        value U
        err   error
    }

    sem := make(chan struct{}, workers)
    results := make(chan indexed, len(items))

    var wg sync.WaitGroup
    for i, item := range items {
        wg.Add(1)
        go func(idx int, v T) {
            defer wg.Done()
            sem <- struct{}{}
            defer func() { <-sem }()

            val, err := fn(ctx, v)
            results <- indexed{index: idx, value: val, err: err}
        }(i, item)
    }

    go func() {
        wg.Wait()
        close(results)
    }()

    output := make([]U, len(items))
    for r := range results {
        if r.err != nil {
            return nil, r.err
        }
        output[r.index] = r.value
    }
    return output, nil
}

Usage:

prices, err := FanOut(ctx, productIDs, 10, func(ctx context.Context, id string) (float64, error) {
    return pricingService.GetPrice(ctx, id)
})

Pattern 6: Generic Cache

A type-safe in-memory cache with expiration.

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

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

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]) Get(key K) (V, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    entry, ok := c.items[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.items[key] = cacheEntry[V]{value: value, expiresAt: time.Now().Add(c.ttl)}
}

Usage:

userCache := NewCache[string, *User](5 * time.Minute)
userCache.Set("u-123", &User{Name: "Alice"})

if user, ok := userCache.Get("u-123"); ok {
    fmt.Println(user.Name)
}

When Not to Use Generics

Generics are not always the right tool. Avoid them when:

  • A concrete type works fine and you have no other types to support.
  • An interface like io.Reader already provides the abstraction you need.
  • The generic version is harder to read than two concrete implementations.

The Go proverb still applies: a little copying is better than a little dependency. If you only need Max for int and float64, two one-line functions are clearer than a generic one with a constraint.

Summary

  • Use generic sets and caches to eliminate map boilerplate.
  • Result types enable functional composition of error-prone operations.
  • Generic repositories reduce CRUD duplication across entity types.
  • FanOut with generics gives you a reusable concurrency primitive.
  • Reach for generics when you find yourself writing the same logic for different types, not before.