When to Avoid Generics in Go APIs
Not every exported function needs bracket syntax.
Search across all documentation pages
Not every exported function needs bracket syntax.
This cheatsheet lists situations where interfaces, concrete types, or small duplicates keep public APIs idiomatic and easier to evolve.
| Signal | Prefer instead | Example |
|---|---|---|
| Single concrete type in production | Non-generic function | ParseConfig(cfg Config) not Parse[T Config](T) |
| Behavior contract, many implementations | Small interface | io.Reader, Validator |
| HTTP/gRPC handler signature | Concrete request/response types | func(w http.ResponseWriter, r *http.Request) |
| Plugin or third-party extensibility | Interface + registration | Middleware chains in chi / gin / echo |
| JSON/dynamic schema | any + typed decode step | json.Unmarshal into struct |
| Smell | Why avoid generics | Better shape |
|---|---|---|
| Brackets in every helper name | Harder grep and docs | Unexported sortItems[T] behind exported SortItems |
| Constraint with 8+ union members | Unstable, unreadable bound | Named domain type or non-generic split |
| Generic exported struct users never parameterize | API noise | Concrete struct or interface field |
| Changing constraint breaks callers silently at compile | Large blast radius | Stable interface surface, generic internals |
| Test doubles need many instantiations | Mock proliferation | Interface boundary |
| Context | Generics risk | Guidance |
|---|---|---|
| WASM / tinygo | Many instantiations inflate binary | One code path or limited codegen |
| Hot path already uses interfaces for plugins | Marginal generic win | Benchmark before rewrite |
| Cross-module version skew | Instantiation symbols multiply | Keep generics in leaf packages |
| Reflection-based frameworks | Generics do not simplify reflection | Stay with interfaces |
| Question | If yes | If no |
|---|---|---|
Will callers use more than one T? | Generic candidate | Concrete API |
| Is this exported? | Favor simpler surface | Internal generic OK |
Does T need methods from different domains? | Interface, not union | Narrow constraint |
| Is readability for junior devs a goal? | Avoid bracket-heavy exports | Internal use fine |
| Does stdlib already provide helper? | Use slices/maps/cmp | Custom generic |
| Goal | Avoid | Prefer |
|---|---|---|
| Sort users by age | func Sort[T any]([]T, func(T,T) bool) exported | slices.SortFunc(users, cmp.Compare) locally |
| Validate models | Validate[T Validator](T) with huge constraint | type Validator interface { Validate() error } |
| Repository get | Get[T any](id string) (T, error) public | GetUser(id string) (User, error) |
Yes - stdlib keeps public APIs mostly non-generic and uses generics inside slices, maps, and cmp.
Follow that split.
Rarely - frameworks expose concrete contexts and interfaces.
Keep generics in shared internal utilities.
Get[T] tempts but spreads bracket syntax and tightens constraints on models.
Prefer domain methods per aggregate.
Only when downstream packages must define compatible generics.
Otherwise keep constraints unexported.
Internal batch helpers and test utilities - not .proto-generated surface.
Generated code stays concrete.
No - concrete types and interfaces are statically checked.
Generics are one tool, not the only safe tool.
Reconcilers stay interface-driven against client-go types.
Use generics in small list/dedupe helpers inside the project.
Lead with simple interfaces and concrete types.
Add generics later without breaking surface when possible.
Exported: Process(items []Item).
Unexported: dedupe[T comparable]([]T) []T.
Two concrete functions with five lines each beat one generic with a ten-member union.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 18, 2026