Real-World Go Generics Examples Beyond Tutorials
Practical Go generics patterns for production code: type-safe collections, result types, repository layers, and functional helpers.
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.Readeralready 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.
FanOutwith generics gives you a reusable concurrency primitive.- Reach for generics when you find yourself writing the same logic for different types, not before.
Related articles
- Go Go Generics: Practical Patterns and Constraints
Learn practical Go generics patterns including type constraints, generic data structures, utility functions, and when to use generics vs interfaces.
- Go Go Concurrency Patterns
Master Go concurrency — goroutines, channels, select, WaitGroups, mutexes, and common patterns like fan-out/fan-in and worker pools.
- Go Go Generics Deep Dive
How Go's generics work in practice: type parameters, constraints, the constraints package, and where generics shine versus interfaces or code generation.
- TypeScript TypeScript Generics: Advanced Patterns and Constraints
Master advanced TypeScript generics patterns including conditional constraints, recursive types, generic factories, and type-safe builder patterns with examples.