Why Generics? — Professional Level¶
Table of Contents¶
- The standard library's adoption journey
slices,maps,cmp— the three anchor packagessync.OnceValue,atomic.Pointer[T]and friends- Migration strategies for production codebases
- Team-level guidelines
- Case study: Kubernetes
- Case study: Hashicorp libraries
- Case study: Database drivers and ORMs
- Case study: Observability and logging
- Case study: Internal Google adoption
- Linters, tooling, and IDE support
- Migration checklist
- Summary
The standard library's adoption journey¶
Go's stdlib is famously conservative. Generics arrived in 1.18 (March 2022) but the stdlib did not adopt them widely until later releases. Here is the timeline:
| Release | Date | Generic additions |
|---|---|---|
| 1.18 | Mar 2022 | Type parameters added; almost no stdlib uses them yet |
| 1.19 | Aug 2022 | Internal stdlib uses; atomic.Pointer[T] |
| 1.20 | Feb 2023 | errors.Join; experimental slices, maps, cmp in golang.org/x/exp |
| 1.21 | Aug 2023 | slices, maps, cmp promoted to stdlib; min, max, clear builtins |
| 1.22 | Feb 2024 | slices.Concat; cmp.Or |
| 1.23 | Aug 2024 | Range-over-func; iter.Seq[T], iter.Seq2[K, V] |
| 1.24 | Feb 2025 | Generic type aliases; weak.Pointer[T] |
The deliberate two-release gap between language support (1.18) and stdlib adoption (1.21) was a quality bar: the team waited to see how the community used generics before locking in stdlib APIs.
slices, maps, cmp — the three anchor packages¶
slices¶
import "slices"
slices.Contains(s, target) // bool
slices.Index(s, target) // int
slices.Sort(s) // in-place sort
slices.SortFunc(s, cmpFunc) // custom compare
slices.Reverse(s)
slices.Equal(a, b)
slices.Compact(s)
slices.Concat(a, b, c) // 1.22+
slices.Min(s) // 1.21+, panics on empty
slices.Max(s)
slices.BinarySearch(s, target)
slices.Insert(s, i, v...)
slices.Delete(s, i, j)
slices.Clone(s)
These replace dozens of one-off helpers that every team used to write. They are also extensively benchmarked and inlined by the Go compiler.
maps¶
import "maps"
maps.Keys(m) // iter.Seq[K] in 1.23+; []K in 1.21
maps.Values(m)
maps.Equal(a, b)
maps.Clone(m)
maps.Copy(dst, src)
maps.DeleteFunc(m, fn)
cmp¶
import "cmp"
cmp.Ordered // constraint
cmp.Compare[T cmp.Ordered](a, b T) int
cmp.Less[T cmp.Ordered](a, b T) bool
cmp.Or[T comparable](vals ...T) T // first non-zero; 1.22+
The cmp.Ordered constraint in particular has become the standard way to write "this T can be compared with <". Hand-rolling your own Ordered is now an anti-pattern.
sync.OnceValue, atomic.Pointer[T] and friends¶
The sync and sync/atomic packages were among the first to adopt generics:
// One-time initialization
var initOnce = sync.OnceValue[*DB](func() *DB { return openDB() })
db := initOnce()
// Type-safe atomic pointer
var p atomic.Pointer[Config]
p.Store(&Config{...})
cfg := p.Load() // *Config — no cast
These small wrappers eliminate years of unsafe.Pointer and interface{} glue. They are the model for what a good generic stdlib API looks like.
Why sync.Pool is not generic¶
sync.Pool.Get() still returns interface{}. The team considered Pool[T] but decided against it because:
- Pools often store multiple types
- The boxing cost is amortised by reuse
- Backwards compatibility for the existing API
This is a useful counter-example: not every API benefits from genericization.
Migration strategies for production codebases¶
A team migrating a large Go codebase to generics has three realistic strategies:
Strategy 1 — Big bang¶
Convert all interface{} helpers in one PR. Fast, but risky: - One mistake breaks the world - Code review burden is huge - Easy to introduce subtle behaviour changes
Used by smaller projects (< 50 kLoC).
Strategy 2 — Trickle migration¶
Add generic versions alongside existing code. Deprecate the old version. Migrate callers gradually.
// Deprecated: use slices.Contains
func Contains(s []string, target string) bool { ... }
// New
func ContainsAny[T comparable](s []T, target T) bool { ... }
Used by Kubernetes, Hashicorp, Cloudflare. Recommended for most teams.
Strategy 3 — Layer-by-layer¶
Migrate one architectural layer at a time: 1. Internal utility libs first 2. Then domain helpers 3. Then public APIs (carefully)
Slow but safe. Used in regulated industries (fintech, healthcare).
Migration anti-patterns¶
- Changing public API in place — every dependent breaks
- Mixing styles within one file — readers get confused
- Forgetting to update CI to require Go 1.18+ before introducing generics
- Generic-ifying without benchmarks — sometimes the old code was faster
Team-level guidelines¶
After observing dozens of teams migrate to generics, consistent patterns emerge.
Adoption phases¶
- Phase 0 — Forbidden. "We are on Go 1.16. Generics do not exist."
- Phase 1 — Internal only. Generics are allowed in
internal/packages but forbidden in public APIs. - Phase 2 — Stdlib-pattern only. Allowed if it mirrors a stdlib pattern (e.g., wraps
slices). - Phase 3 — Idiomatic. Used everywhere it makes sense.
Most teams sit in Phase 2 for at least a year before reaching Phase 3.
Style guides¶
A typical team rulebook looks like:
- Use
any, notinterface{}, in new code.- Prefer
slices.Xandmaps.Xover hand-rolled equivalents.- Type parameters: single uppercase letters (
T,K,V,E).- New generic helpers must be added to
internal/util/genericfirst.- Generic public API requires two reviewers and benchmark numbers.
- No method-level type parameters (they don't exist in Go anyway).
- Document any non-obvious constraint.
Code review checklist¶
| Check | Why |
|---|---|
| Does the function need to be generic? | Avoid premature abstraction |
| Is the constraint as loose as possible? | Tightening later is hard |
| Are type parameter names idiomatic? | Readability |
| Is type inference working at all call sites? | Sometimes manual instantiation is needed |
| Are there benchmarks if it replaces a hot path? | Performance regressions are subtle |
Case study: Kubernetes¶
Kubernetes is one of the largest Go codebases (5+ million LoC). Its lister/informer/cache layers were built before generics with massive amounts of generated code — every API resource (Pod, Service, Deployment, …) had its own typed lister, generated by client-gen.
After Go 1.18 the SIG API Machinery team introduced experimental generic helpers:
// Before
type PodLister interface {
List(selector labels.Selector) ([]*v1.Pod, error)
Get(name string) (*v1.Pod, error)
}
type ServiceLister interface { ... } // identical structure
type DeploymentLister interface { ... }
// After (experimental)
type GenericLister[T runtime.Object] interface {
List(selector labels.Selector) ([]T, error)
Get(name string) (T, error)
}
The full migration is still in progress — even years after 1.18 — because Kubernetes' API stability guarantees prevent quick breaking changes. But the client-go shared informers have been quietly using generics under the hood since 1.21.
Lessons from Kubernetes: - Public API migration is slow when stability matters - Internal layers can adopt generics aggressively - Codegen still has a role for things like deepcopy, conversion, and protobuf — generics did not kill it
Case study: Hashicorp libraries¶
Hashicorp maintains widely used libraries: hashicorp/golang-lru, hashicorp/go-multierror, hashicorp/hcl. Their migration approach was textbook:
golang-lru — generic LRU cache¶
// v1 (pre-generics)
import "github.com/hashicorp/golang-lru"
cache, _ := lru.New(128)
cache.Add("key", value)
v, _ := cache.Get("key")
v.(string) // type assertion required
// v2 (post-generics)
import "github.com/hashicorp/golang-lru/v2"
cache, _ := lru.New[string, *User](128)
cache.Add("key", &User{...})
v, _ := cache.Get("key") // v is *User, no assertion
Hashicorp shipped this as a new major version (/v2), not as a breaking change to v1. This is the canonical approach: parallel module path, semver bump, gradual migration.
Lessons¶
- New major version for breaking generic refactors
- Keep v1 alive for callers who cannot upgrade
- Document migration path clearly in the README
Case study: Database drivers and ORMs¶
database/sql itself is not generic — Scan(dest ...interface{}) still works the old way. But many ORMs and helpers have gone generic:
sqlx-style helpers¶
type User struct {
ID int
Name string
}
// Generic helper
func QueryOne[T any](db *sql.DB, query string, args ...any) (*T, error) {
row := db.QueryRow(query, args...)
var t T
if err := scanInto(row, &t); err != nil { return nil, err }
return &t, nil
}
u, err := QueryOne[User](db, "SELECT * FROM users WHERE id = ?", 1)
Frameworks like gorm, ent, sqlboiler, and bun all have generic query APIs now. The user no longer writes Scan(&u) — the framework does it.
Caveats¶
- Reflection is still required to map column names to struct fields
- Generics give you the return type, not the magic of mapping
- Performance is not free — the generic wrapper still calls
reflect
Case study: Observability and logging¶
log/slog (added in Go 1.21) does not use generics for its main API:
It uses ...any because logs are inherently variadic and heterogeneous. Generics are not the right tool here.
But auxiliary helpers in metrics libraries (Prometheus, OpenTelemetry) often use generics:
Lesson: logging is heterogeneous (use any), metrics are homogeneous (use generics).
Case study: Internal Google adoption¶
Google's monorepo contains millions of lines of Go. Their internal style guide (excerpts have been published in talks):
- Generics are opt-in for new code, not mandatory for old.
- Internal libraries (
util/,internal/) may use generics freely. - Public APIs of products (Cloud SDK, gRPC, etc.) introduce generics only with explicit design review.
- Generated code (protobuf, RPC stubs) is still generated, not generic.
context.Contextwas considered for genericization — and rejected. Some abstractions resist generics.
The Google experience confirms what smaller teams discover: generics solve specific problems excellently and are useless for others.
Linters, tooling, and IDE support¶
After 1.18, the Go ecosystem caught up:
| Tool | Generic support |
|---|---|
gofmt | Day 1 |
gopls | Day 1, with bugs ironed out by 1.20 |
staticcheck | 1.19+ (with new generic checks like SA9009) |
revive | Mid-2022 |
golangci-lint | Bundled support shortly after |
| GoLand | Day 1 |
| VS Code Go | Improved gradually 2022-2023 |
dlv (debugger) | Some quirks with stenciled bodies |
pprof | Generic functions appear with mangled names |
Two practical tips:
pprofflame graphs show generic functions aspkg.F[go.shape.int]— the suffix tells you the GC shape, useful for performance work.go vethas a new check (-shadow) for type parameters shadowing types in scope.
Migration checklist¶
A pragmatic checklist for a team about to start using generics:
- Go version bumped to 1.18 or newer in
go.mod(recommend 1.21+ for stdlibslices/maps) - CI passes with
-tags generics(no longer needed in 1.21+) - Style guide updated with naming conventions
- At least one team member has read the intro-generics blog post
- Internal
internal/util/genericpackage created for shared helpers - Linter rules updated (
staticcheck,golangci-lint) - Public API decisions documented in design doc
- Benchmarks added for generic versions of hot paths
- Old
interface{}helpers marked deprecated, not deleted - CONTRIBUTING.md updated with generic guidelines
- Onboarding docs include the difference between
any,comparable, andcmp.Ordered
Summary¶
The professional view of generics is strategic, not tactical. A working engineer must:
- Understand the stdlib's gradual adoption and use
slices,maps,cmpfirst. - Pick a migration strategy that matches the team's risk tolerance.
- Codify team rules — naming, constraint policy, public API rules.
- Learn from real case studies — Kubernetes' slow migration, Hashicorp's
/v2model, Google's selective adoption. - Keep tooling current — generics changed how
pprof,gopls, and linters work.
Generics are now a normal part of Go. A professional team treats them like any other language feature: a tool with costs and benefits, used deliberately, reviewed carefully, measured for performance.
The next file (specification.md) digs into the formal grammar of type parameters, so you can read the Go spec confidently.