Journal / Engineering

Go generics in production: the patterns worth reaching for.

Go got generics in 1.18. We spent a year ignoring them, six months using them wrong, and the last year finding the handful of patterns that actually belong in a production codebase. This is that list.

Published 13 Jun 2026 9 min read
[T] RBB/LAB ENGINEERING RBB LAB · JOURNAL 9 MIN READ

Go added generics in March 2022, with 1.18. We noticed. We read the spec, ran the examples, and then largely ignored the feature for the next eighteen months. This is not unusual for us. Go's strength has always been its resistance to complexity, and generics felt, at first, like the language was losing an argument it had been winning for a decade.

We were not entirely wrong. The first implementations were rough. Error messages when a type constraint was not satisfied read like the compiler was having a bad day. Compile times got measurably worse. The constraints package lived in golang.org/x/exp, which is the Go ecosystem's way of saying "we're not sure about this yet." The community was divided between people who had wanted generics for years and were vindicated, and people who thought interface{} had always been sufficient and were suspicious.

By 1.21, things had settled. The standard library gained slices, maps, and cmp packages built on generics. The error messages got better. The constraints that mattered most, like cmp.Ordered, moved into the standard library. We started picking up the feature in earnest, and over the following year, four patterns emerged that we now reach for regularly. Everything else, we left alone.

Why we were slow to adopt

Go's whole culture is "resist complexity." The language has a concept it calls the "pit of success": make the right thing easy and the wrong thing hard. Go's original answer to the generic problem was the interface. Any type that implements a method set satisfies the interface. You write one function, it accepts any conforming type, and the compiler checks conformance at the call site. This is clean, readable, and fast to compile. It handles the majority of polymorphism that production code actually needs.

The interface approach has one real limit: it does not help when the operation does not involve methods. Filtering a slice of any type, finding the minimum of two numbers, writing a single CRUD implementation for any entity - these operations are structural, not behavioural. Before generics, the escape hatches were copying code for each type, using interface{} and accepting the type assertion overhead and the lost compile-time safety, or writing a code generator and explaining it to every new engineer who joined. None of those options were good. Generics fix exactly this class of problem, and only this class. That narrowness is what makes them worth using.

Generics fix exactly one class of problem. That narrowness is what makes them worth using.

Pattern: collection utilities

The most immediately useful thing generics enable is a small set of collection helpers. Note that as of Go 1.21, slices.Contains, slices.Index, slices.Sort, and several other operations are in the standard library - use those. Map, Filter, and Reduce are absent by deliberate design: the Go team concluded that a plain for loop is usually clearer than three chained generic calls. We disagree for pipeline code, which is why we still write them - but the omission is intentional, not an oversight.

Before generics, the honest options were all bad. Either you copied the same loop body for every domain type in your codebase, or you accepted interface{} and lost the type system:

collections_before.go// The copy-paste approach. Repeated for every type.
func FilterUsers(s []User, fn func(User) bool) []User {
	out := make([]User, 0, len(s))
	for _, v := range s {
		if fn(v) {
			out = append(out, v)
		}
	}
	return out
}

// Same logic, different type. Again. And again.
func FilterOrders(s []Order, fn func(Order) bool) []Order {
	out := make([]Order, 0, len(s))
	for _, v := range s {
		if fn(v) {
			out = append(out, v)
		}
	}
	return out
}

The generic version collapses this to three functions, written once, that work for any element type:

collections.gopackage collections

func Map[In, Out any](s []In, fn func(In) Out) []Out {
	out := make([]Out, len(s))
	for i, v := range s {
		out[i] = fn(v)
	}
	return out
}

func Filter[T any](s []T, fn func(T) bool) []T {
	out := make([]T, 0, len(s))
	for _, v := range s {
		if fn(v) {
			out = append(out, v)
		}
	}
	return out
}

func Reduce[T, Acc any](s []T, init Acc, fn func(Acc, T) Acc) Acc {
	acc := init
	for _, v := range s {
		acc = fn(acc, v)
	}
	return acc
}

The built-in comparable constraint is worth a note here: it means "any type that supports ==." Go's type system prevents you from accidentally calling Filter with a function type or a slice as the element type - the compiler rejects that at the call site. This is the feature: you get the generality of a single implementation with the safety of a typed contract.

Pattern: Result type

This one is more opinionated, and you should use it sparingly. Go's (T, error) pair is idiomatic, explicit, and most of the time the right tool. The pattern falls apart specifically in pipeline code, where you are chaining three or four operations each of which can fail, and every intermediate step requires the same boilerplate check. The result is not wrong; it is just noisy in a way that buries the actual logic:

process_before.gofunc processRecord(ctx context.Context, id int64) (*Report, error) {
	record, err := db.FindRecord(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("find: %w", err)
	}
	validated, err := validate(record)
	if err != nil {
		return nil, fmt.Errorf("validate: %w", err)
	}
	report, err := buildReport(ctx, validated)
	if err != nil {
		return nil, fmt.Errorf("build: %w", err)
	}
	return report, nil
}

A lightweight Result[T] type with a single Then combinator can clean this up without importing a library or abandoning Go's error model:

result.gotype Result[T any] struct {
	val T
	err error
}

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

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

// Then chains a fallible step, short-circuiting on error.
func Then[T, U any](r Result[T], fn func(T) Result[U]) Result[U] {
	if !r.IsOk() {
		return Fail[U](r.err)
	}
	return fn(r.val)
}

// The calling code becomes a readable pipeline:
return Then(
	Then(findRecord(ctx, id), validate),
	func(v *Record) Result[*Report] {
		return buildReport(ctx, v)
	},
).Unwrap()

The honest caveat: this pattern fits a specific shape of code and does not generalise well. If your function has branching logic, needs to log intermediate states, or has steps that are not cleanly composable, the explicit if err != nil chain is still the right call. We use Result[T] in about one in ten places where we could, and the rest stays idiomatic. The goal is not to rewrite Go's error handling; it is to have a tool for the slice of code where chaining genuinely improves readability.

Pattern: typed numeric constraints

Go 1.21 added cmp.Ordered to the standard library: an interface satisfied by all integer and floating-point types, as well as string. This eliminates a category of copy-paste that shows up in almost every backend: MinInt64, MaxFloat64, ClampDuration, and every other variant of the same two-line function written once per type.

math.goimport "cmp"

func Min[T cmp.Ordered](a, b T) T {
	if a < b { return a }
	return b
}

func Max[T cmp.Ordered](a, b T) T {
	if a > b { return a }
	return b
}

func Clamp[T cmp.Ordered](v, lo, hi T) T {
	return Max(lo, Min(hi, v))
}

// For arithmetic operations, define a custom union constraint.
// The ~ prefix means "any type whose underlying type is X".
type Number interface {
	~int | ~int32 | ~int64 | ~float32 | ~float64
}

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

// type Price float64 satisfies ~float64, so Sum works on it directly.
type Price float64
total := Sum([]Price{10.50, 8.25, 3.00}) // Price(21.75)

The ~ prefix is the part of Go's type constraint syntax that earns its keep most quietly. A type like type Price float64 is a distinct named type, not assignable to float64 in the type system. Without ~, your generic function would refuse it. With ~, the constraint matches any type whose underlying representation is float64. Domain types, unit types, newtype wrappers - all work without manual conversion, and the compiler still prevents mixing them accidentally at the call site.

One performance caveat worth knowing: the Go compiler implements generics via GC-shape stenciling - it emits one specialised body per memory layout, not per concrete type. A Number constraint spanning five numeric types works correctly, but wide union constraints can trigger indirect calls on every arithmetic operation, because the compiler cannot always inline through the union. In tight numeric loops, a hand-written func sumInt64(xs []int64) int64 will outperform Sum[T Number]. Keep the union narrow, or profile before assuming the generic version is free.

Pattern: generic repository

Most backends have some version of the same CRUD interface written for every entity in the domain. User, Order, Transaction, Account - the signature is always the same, only the type changes. Before generics, the options were an interface per entity (verbose) or a shared interface returning interface{} (unsafe). A generic repository interface replaces both:

repository_before.gotype UserRepository interface {
	Find(ctx context.Context, id int64) (*User, error)
	FindAll(ctx context.Context) ([]*User, error)
	Save(ctx context.Context, u *User) error
	Delete(ctx context.Context, id int64) error
}

// Identical shape for every entity. Repeated until someone gets annoyed.
type OrderRepository interface {
	Find(ctx context.Context, id int64) (*Order, error)
	FindAll(ctx context.Context) ([]*Order, error)
	Save(ctx context.Context, o *Order) error
	Delete(ctx context.Context, id int64) error
}
repository.gotype Entity interface {
	GetID() int64
}

type Repository[T Entity] interface {
	Find(ctx context.Context, id int64) (T, error)
	FindAll(ctx context.Context) ([]T, error)
	Save(ctx context.Context, entity T) error
	Delete(ctx context.Context, id int64) error
}

// One concrete implementation covers every entity type.
type postgresRepo[T Entity] struct {
	db    *sql.DB
	table string
	scan  func(*sql.Row) (T, error)
}

func (r *postgresRepo[T]) Find(ctx context.Context, id int64) (T, error) {
	row := r.db.QueryRowContext(ctx,
		"SELECT * FROM "+r.table+" WHERE id = $1", id,
	)
	return r.scan(row)
}

func (r *postgresRepo[T]) Delete(ctx context.Context, id int64) error {
	_, err := r.db.ExecContext(ctx,
		"DELETE FROM "+r.table+" WHERE id = $1", id,
	)
	return err
}

The honest limit of this pattern is that it handles exactly the generic part of a repository, and real repositories are not fully generic. User needs a FindByEmail. Order needs a FindByDateRange. When those requirements arrive, embed Repository[T] in a domain-specific interface and add the entity-specific methods there. The generic implementation handles the boilerplate; the typed extension handles the specifics. The two layers compose cleanly, and you stop re-writing Find and Delete for the fourth time.

Where we still don't use them

Three places where we tried generics and went back.

Method-level generics do not exist. Go allows type parameters on types and on package-level functions. It does not allow them on methods. You cannot write func (s *Service) Process[T any](v T). This is a deliberate language decision, and it means the patterns above only work as functions or on types you control. If your instinct is to add a type parameter to a method, the language is telling you to reach for an interface instead.

Complex nested inference still requires help. Type inference widened significantly in Go 1.21 and now handles most single-level cases without annotation - including generic functions passed as arguments to other generic functions. Where it still falls short is deeply nested chains where the compiler cannot resolve the type from context alone. When you find yourself writing Then[*Record, *Report](... to help the compiler, that is a signal the chain has grown beyond what the pattern earns back in readability.

Interfaces still own behaviour polymorphism. Generics and interfaces are complementary, not competing. Use generics when the operation is structural - "any type that supports equality," "any type that has an ID." Use interfaces when the operation is behavioural - "any type that can serialize itself," "any type that knows how to validate." Trying to replace interface dispatch with generics produces code that is harder to read and does not benefit from Go's interface composition. The language has two tools for a reason.

The takeaway

Four years in, Go generics have not changed how we write Go. They have removed a small set of compromises we made in specific situations - the copy-paste collection helper, the boilerplate repository, the type-unsafe math utility, the error-handling pipeline that buried its logic under repeated checks. The code we write today in those places is shorter, safer, and equally readable to someone who has never seen the feature. That is a good outcome. It is also a modest one, which is exactly right. Go's strength is its restraint, and the correct use of generics is restrained. Write the four patterns. Leave the rest alone.

ER
Enrico Rubboli
CEO · San Marino
Principal engineer at RBB LAB, building production software for businesses and founders.
Stay in the loop

One email when we publish. Nothing else.

About once a month. Sometimes less. No funnels, no drip campaigns.

Or grab the RSS · Follow on LinkedIn / X