Generic Types & Interfaces — Professional Level¶
Table of Contents¶
- Production goals
- Pattern 1 — typed concurrent map
- Pattern 2 — generic event bus
- Pattern 3 — generic repository
- Pattern 4 — generic builder
- Pattern 5 — generic in-memory cache with TTL
- Pattern 6 — generic worker pool
- Pattern 7 — generic pipeline
- Pattern 8 — generic result wrapper
- Code review checklist
- Team conventions
- Library design checklist
- Anti-patterns to flag
- Migration from
interface{}codebase - Real metrics from production
- Summary
Production goals¶
In a real codebase, generic types and interfaces have to fulfil four jobs at once:
- Reduce duplication — one
Cache, oneRepository, oneEventBus. - Preserve type safety — no
interface{}, no runtime casts at boundaries. - Stay debuggable — error messages, stack traces, and logs should still be sensible.
- Stay reviewable — generic code is harder to read than non-generic code, so the API surface must remain narrow and consistent.
The patterns below are battle-tested ones drawn from public Go libraries and large in-house repositories.
Pattern 1 — typed concurrent map¶
A direct, type-safe wrapper around sync.Map is one of the most common generic types in production Go.
package gomap
import "sync"
type SyncMap[K comparable, V any] struct {
m sync.Map
}
func (s *SyncMap[K, V]) Load(key K) (V, bool) {
v, ok := s.m.Load(key)
if !ok {
var zero V
return zero, false
}
return v.(V), true
}
func (s *SyncMap[K, V]) Store(key K, value V) { s.m.Store(key, value) }
func (s *SyncMap[K, V]) LoadOrStore(key K, value V) (V, bool) {
actual, loaded := s.m.LoadOrStore(key, value)
return actual.(V), loaded
}
func (s *SyncMap[K, V]) Delete(key K) { s.m.Delete(key) }
func (s *SyncMap[K, V]) Range(fn func(K, V) bool) {
s.m.Range(func(k, v any) bool {
return fn(k.(K), v.(V))
})
}
Why we keep sync.Map underneath¶
sync.Map has hand-tuned read-heavy optimizations (lazy promotion, atomic snapshots). Re-implementing it from scratch is risky. The wrapper just gives type safety; the core algorithm stays the same.
Cost¶
The v.(V) and k.(K) assertions are small but real. For pure value types they perform an unbox; for pointer types they are usually a no-op after the compiler has the type info. Benchmark on your workload before optimizing further.
Variation — sharded map for high contention¶
type ShardedMap[K comparable, V any] struct {
shards [numShards]struct {
mu sync.RWMutex
m map[K]V
}
hash func(K) uint64
}
The hash function is parameter-aware (e.g., for string use FNV; for int use the value). You can take a hash function as a constructor argument or constrain K further.
Pattern 2 — generic event bus¶
Event-driven architecture benefits massively from generics — every event type used to require its own bus or a shared interface{} bus with type assertions.
package eventbus
import "sync"
type Handler[E any] func(E)
type EventBus[E any] struct {
mu sync.RWMutex
handlers []Handler[E]
}
func New[E any]() *EventBus[E] {
return &EventBus[E]{}
}
func (b *EventBus[E]) Subscribe(h Handler[E]) func() {
b.mu.Lock()
defer b.mu.Unlock()
b.handlers = append(b.handlers, h)
idx := len(b.handlers) - 1
return func() {
b.mu.Lock()
defer b.mu.Unlock()
b.handlers[idx] = nil
}
}
func (b *EventBus[E]) Publish(e E) {
b.mu.RLock()
handlers := append([]Handler[E](nil), b.handlers...) // snapshot
b.mu.RUnlock()
for _, h := range handlers {
if h != nil {
h(e)
}
}
}
Usage¶
type OrderPlaced struct{ ID string; Amount int64 }
bus := eventbus.New[OrderPlaced]()
unsub := bus.Subscribe(func(e OrderPlaced) {
log.Printf("order placed: %s, %d", e.ID, e.Amount)
})
defer unsub()
bus.Publish(OrderPlaced{ID: "o-42", Amount: 9_900})
Each event type gets its own bus instance with its own handler set. Compile-time check stops you from publishing the wrong type.
Discussion — channel-based variant¶
type ChanBus[E any] struct {
mu sync.RWMutex
subs []chan E
}
func (b *ChanBus[E]) Subscribe(buf int) <-chan E { /* ... */ }
func (b *ChanBus[E]) Publish(e E) { /* ... */ }
Channel-based buses give back-pressure for free but require careful close semantics. Pick based on your deployment.
Pattern 3 — generic repository¶
The repository pattern hides storage details behind a narrow interface. With generics, the boilerplate drops dramatically.
package repo
import (
"context"
"errors"
)
var ErrNotFound = errors.New("not found")
type ID interface{ ~string | ~int64 }
type Repository[T any, K ID] interface {
Get(ctx context.Context, id K) (T, error)
Save(ctx context.Context, v T) (K, error)
Delete(ctx context.Context, id K) error
List(ctx context.Context, limit, offset int) ([]T, error)
}
SQL implementation¶
type SQLRepo[T any, K ID] struct {
db *sql.DB
table string
selectQ string
insertQ string
deleteQ string
listQ string
scan func(*sql.Rows) (T, error)
keyFromT func(T) K
}
func (r *SQLRepo[T, K]) Get(ctx context.Context, id K) (T, error) {
var zero T
rows, err := r.db.QueryContext(ctx, r.selectQ, id)
if err != nil { return zero, err }
defer rows.Close()
if !rows.Next() { return zero, ErrNotFound }
return r.scan(rows)
}
func (r *SQLRepo[T, K]) Save(ctx context.Context, v T) (K, error) {
k := r.keyFromT(v)
_, err := r.db.ExecContext(ctx, r.insertQ, k, /* ... */)
return k, err
}
Per-domain configuration¶
type User struct {
ID string
Email string
}
func scanUser(rows *sql.Rows) (User, error) {
var u User
return u, rows.Scan(&u.ID, &u.Email)
}
userRepo := &SQLRepo[User, string]{
db: db,
table: "users",
selectQ: "SELECT id, email FROM users WHERE id = $1",
insertQ: "INSERT INTO users (id, email) VALUES ($1, $2)",
deleteQ: "DELETE FROM users WHERE id = $1",
listQ: "SELECT id, email FROM users LIMIT $1 OFFSET $2",
scan: scanUser,
keyFromT: func(u User) string { return u.ID },
}
Trade-off — SQL strings vs query builder¶
The SQL strings are still hand-written per domain. Some teams parameterize further with code generation or a query builder. The right balance depends on schema diversity.
Discussion — when not to use this pattern¶
- When CRUD differs significantly per domain, a generic repository becomes a leaky abstraction — better to write specialized repositories.
- When the storage uses fundamentally different paradigms (graph, document, time-series), the abstraction starts to lie.
Pattern 4 — generic builder¶
A builder lets you assemble a complex value step by step. With generics, the same builder shape works for many concrete types.
package httpclient
type Builder[Cfg any] struct {
cfg Cfg
}
func New[Cfg any](initial Cfg) *Builder[Cfg] {
return &Builder[Cfg]{cfg: initial}
}
func (b *Builder[Cfg]) With(modify func(*Cfg)) *Builder[Cfg] {
modify(&b.cfg)
return b
}
func (b *Builder[Cfg]) Build() Cfg { return b.cfg }
Usage:
type Cfg struct {
Timeout time.Duration
Retries int
BaseURL string
}
cfg := New(Cfg{Timeout: 30 * time.Second}).
With(func(c *Cfg) { c.Retries = 3 }).
With(func(c *Cfg) { c.BaseURL = "https://api.example.com" }).
Build()
A "functional options" generic variant:
type Option[T any] func(*T)
func Build[T any](initial T, opts ...Option[T]) T {
cfg := initial
for _, o := range opts { o(&cfg) }
return cfg
}
func WithRetries(n int) Option[Cfg] { return func(c *Cfg) { c.Retries = n } }
This pairs the well-known options pattern with generics so options can themselves be type-parameterized.
Pattern 5 — generic in-memory cache with TTL¶
package cache
import (
"sync"
"time"
)
type entry[V any] struct {
value V
expiresAt time.Time
}
type Cache[K comparable, V any] struct {
mu sync.RWMutex
m map[K]entry[V]
ttl time.Duration
}
func New[K comparable, V any](ttl time.Duration) *Cache[K, V] {
return &Cache[K, V]{m: make(map[K]entry[V]), ttl: ttl}
}
func (c *Cache[K, V]) Get(k K) (V, bool) {
c.mu.RLock()
e, ok := c.m[k]
c.mu.RUnlock()
if !ok || time.Now().After(e.expiresAt) {
var zero V
return zero, false
}
return e.value, true
}
func (c *Cache[K, V]) Set(k K, v V) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = entry[V]{value: v, expiresAt: time.Now().Add(c.ttl)}
}
func (c *Cache[K, V]) Delete(k K) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.m, k)
}
// StartCleaner runs a background goroutine that periodically deletes expired entries.
func (c *Cache[K, V]) StartCleaner(interval time.Duration, stop <-chan struct{}) {
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-t.C:
now := time.Now()
c.mu.Lock()
for k, e := range c.m {
if now.After(e.expiresAt) { delete(c.m, k) }
}
c.mu.Unlock()
case <-stop:
return
}
}
}()
}
Cache[string, *User] and Cache[int, []byte] are different concrete caches in the same code base, with no boxing.
Pattern 6 — generic worker pool¶
package workerpool
import (
"context"
"sync"
)
type Job[In, Out any] struct {
Input In
Result chan<- Result[Out]
}
type Result[Out any] struct {
Value Out
Err error
}
type Pool[In, Out any] struct {
jobs chan Job[In, Out]
wg sync.WaitGroup
fn func(context.Context, In) (Out, error)
}
func NewPool[In, Out any](workers int, fn func(context.Context, In) (Out, error)) *Pool[In, Out] {
return &Pool[In, Out]{
jobs: make(chan Job[In, Out], workers*2),
fn: fn,
}
}
func (p *Pool[In, Out]) Start(ctx context.Context, n int) {
for i := 0; i < n; i++ {
p.wg.Add(1)
go func() {
defer p.wg.Done()
for j := range p.jobs {
v, err := p.fn(ctx, j.Input)
j.Result <- Result[Out]{Value: v, Err: err}
}
}()
}
}
func (p *Pool[In, Out]) Submit(j Job[In, Out]) { p.jobs <- j }
func (p *Pool[In, Out]) Stop() {
close(p.jobs)
p.wg.Wait()
}
NewPool[string, []byte](10, fetchURL) gives a typed worker pool — no interface{} casts.
Pattern 7 — generic pipeline¶
A pipeline takes a function for each stage and chains them. Each stage may transform T → U, so we use top-level generic functions (not methods).
package pipeline
import "context"
func Stage[In, Out any](
ctx context.Context,
in <-chan In,
fn func(In) (Out, error),
) (<-chan Out, <-chan error) {
out := make(chan Out)
errs := make(chan error, 1)
go func() {
defer close(out)
for v := range in {
select {
case <-ctx.Done(): errs <- ctx.Err(); return
default:
}
r, err := fn(v)
if err != nil { errs <- err; return }
out <- r
}
}()
return out, errs
}
Composing:
parsed, e1 := pipeline.Stage(ctx, lines, parseLine)
filtered, e2 := pipeline.Stage(ctx, parsed, filterRow)
sums, e3 := pipeline.Stage(ctx, filtered, computeSum)
The element type changes at each stage (string → Row → Row → Total) and is inferred by the compiler from fn.
Pattern 8 — generic result wrapper¶
A Result[T] packages a value-or-error into a single move-able envelope. Useful for collecting results from concurrent operations.
type Result[T any] struct {
Value T
Err error
}
func Ok[T any](v T) Result[T] { return Result[T]{Value: v} }
func Fail[T any](err error) Result[T] { return Result[T]{Err: err} }
func (r Result[T]) Unwrap() (T, error) { return r.Value, r.Err }
Pairs well with channels:
out := make(chan Result[Order], len(ids))
for _, id := range ids {
go func(id string) {
o, err := repo.Get(ctx, id)
if err != nil { out <- Fail[Order](err); return }
out <- Ok(o)
}(id)
}
for i := 0; i < len(ids); i++ {
r := <-out
if r.Err != nil { /* handle */ continue }
process(r.Value)
}
Code review checklist¶
When reviewing a pull request that adds or changes a generic type:
- Is the parameter list small (≤ 3)?
- Are constraints chosen tightly (
comparablewhere possible,anyonly when needed)? - Are receivers consistent (all pointer or all value)?
- Are zero-value returns correct (
var zero T)? - Are exported fields documented per parameter?
- Is there at least one test that uses two different
Ts? - Does the type avoid runtime type assertions on its own
T? - Are no method-level type parameters being attempted (would not compile, but stop early refactors)?
- Are interfaces clearly value or constraint (no half-half mixing)?
- Are concurrency invariants documented if the type is shared?
- Are benchmarks added if it sits on a hot path?
- Does the type identity (
pkg.Stack[int]) match the package's naming convention?
Team conventions¶
A consistent in-repo style for generics pays off quickly:
- Parameter names:
Tfor value,K/Vfor key/value,Efor element/event,Rfor result,In/Outfor pipeline edges. - Constructor:
New[T]()for unparameterized creation,NewT[T](...)only if there is a separate creation flow. - Pointer receivers for any method that touches mutable state.
- One generic type per file when the type is non-trivial — easier to follow.
- Doc comments: explicitly mention each parameter and its constraint.
// Cache is a TTL-bound in-memory cache.
//
// K is the key type. It must be comparable (used in the underlying map).
// V is the value type. It can be any type.
type Cache[K comparable, V any] struct { /* ... */ }
Library design checklist¶
For a generic library (intended to be imported by other packages):
- All exported types have unambiguous names (
gomap.SyncMap, notgomap.M). - Parameter names and order are stable across versions.
- No "magic" constraints — constraints are either standard (
any,comparable,cmp.Ordered) or defined and exported in the library. - Examples in
_test.gofiles showing typical instantiations. - Benchmarks for at least one representative
T. - Compatibility with go vet, staticcheck, golangci-lint.
- No dependence on internal compiler details (e.g., do not assume monomorphization is happening).
Anti-patterns to flag¶
| Anti-pattern | Why it's bad |
|---|---|
Set[any] | Defeats the type safety; equivalent to map[any]struct{} |
Type assertion inside a method on Stack[T] | Indicates T is wrong or another parameter is needed |
func (s *Stack[T]) X() and func (s Stack[T]) Y() mixing receivers | Violates method-set consistency rules |
| Generic type embedded in another generic type with mismatched constraints | Will compile but is fragile under refactoring |
Using reflect on T to special-case behavior | The constraint should encode it instead |
Returning *Stack[T] from a method that "should" return a transformed *Stack[U] | Method type parameters are not allowed; refactor to free function |
| Generics as a workaround for missing variance | Go does not have variance; do not fake it |
Migration from interface{} codebase¶
Real-world projects pre-1.18 are full of interface{} containers. A safe migration plan:
Step 1 — list the abstractions¶
Cache, Set, EventBus, Repository, Result, Option. Each of these may exist in your codebase under various names.
Step 2 — pick one, write the generic version alongside¶
Do not delete the old type. Add CacheV2[K, V] next to it.
Step 3 — port one call site¶
Replace cache.Cache with cache.CacheV2[K, V] in one file. Run tests. Resolve type-assertion errors.
Step 4 — repeat across the codebase¶
When all call sites are ported, delete the original Cache. Rename CacheV2 → Cache.
Step 5 — backfill tests¶
Tests written for interface{} may not catch type-mismatch bugs the new system does catch. Refresh the test suite.
This staged approach makes a large generics migration tractable.
Real metrics from production¶
The numbers vary per workload, but typical findings:
- Boxing avoidance in a generic
Set[int64]vsmap[interface{}]struct{}: 30–60% memory savings, 20–40% lookup speed. sync.Maptyped wrapper adds essentially zero overhead vs rawsync.Map; the type assertion is cheap.- Generic event bus vs
interface{}bus: similar throughput; the value comes from compile-time safety, not raw speed. - Generic repository complies with go vet, fewer runtime panics from misuse.
- Compile time of a heavily generic package can rise by 20–50%; usually still acceptable.
Always measure on your own workload before quoting numbers.
Summary¶
Production usage of generic types and interfaces clusters around a small set of patterns: typed concurrent maps, event buses, repositories, builders, caches, worker pools, pipelines, and result wrappers. The patterns share traits — small parameter lists, consistent receivers, narrow exported APIs, and clear concurrency stories. Code review and team conventions matter as much as the patterns themselves; generics introduce cognitive load that careful style can offset.
End of professional.md.