Generics vs Interfaces — Professional Level¶
Table of Contents¶
- The library author's view
sort.Interfacevsslices.Sort- Why
io.Readerstays as an interface container/listvs a generic Stack- Backwards-compatible migrations
- The Hashicorp pattern
- Designing for two generations of users
- Lessons from the standard library
- Summary
The library author's view¶
A library author has different incentives from an application engineer. Library code lives for years, used by thousands of consumers, with no central control over which Go version they run. The decision matrix is heavier:
| Concern | Application code | Library code |
|---|---|---|
| Time to first deploy | Hours | Months |
| Number of consumers | Few | Many |
| Cost of breaking change | Internal release | Major-version bump |
| Pressure to migrate to generics | Developer ergonomics | Backwards compatibility |
Professional library authors have settled on a few patterns the rest of us can copy.
sort.Interface vs slices.Sort¶
The sort package, shipped in 2009, is the canonical interface-based API:
Every type that wants to be sorted implements three methods. The library is small, the surface tiny, and the same algorithm sorts anything.
In Go 1.21 the team added slices.Sort:
What changed?
| Aspect | sort.Sort | slices.Sort |
|---|---|---|
| Type safety | None at the slice — interface{} underneath | Full — []T |
| Performance | Indirect dispatch on every compare | Inlinable comparator |
| Boilerplate | Three methods or a closure-based wrapper | One call |
| Pre-1.21 callers | Unaffected | Need newer Go |
The team did not remove sort.Sort. They added the generic version alongside. This is the canonical pattern: add the generic, keep the interface, deprecate gently.
The takeaway for library authors¶
- A generic API can be faster and cleaner for the common case.
- An interface API can stay stable for the long tail.
- Both can coexist. Removing the old one is rarely worth the breakage.
Why io.Reader stays as an interface¶
io.Reader is the most-imitated interface in the Go ecosystem:
There is no io.Reader[T] generic equivalent, and there will not be. Why?
-
Each implementation reads from a different source.
*os.Filereads from disk,*bytes.Readerfrom memory,*tls.Connfrom a TCP socket plus crypto state. The body is genuinely different per type. That is subtype polymorphism. -
The data type is always
[]byte. There is noTto vary. A reader of strings would be a different abstraction (bufio.Scanner,iter.Seq[string]). -
The ecosystem composes via interfaces.
io.Copy(dst, src)accepts anyReaderand anyWriter. Theio.LimitReader,io.MultiReader,io.TeeReaderwrappers all rely on the interface chain. Generics would force every wrapper to be parametrized overT, which is redundant whenTis always[]byte. -
Late binding. Third-party libraries (gzip, encryption, network protocols) provide their own
Readerimplementations without touchingio. Generics close the type set; interfaces leave it open.
The lesson: when the shape varies, generics. When the behaviour varies, interfaces. io.Reader varies behaviour, not shape, so it stays.
What about iter.Seq[T]?¶
Go 1.23 added iter.Seq[T any] for range-over-func. This is not the same as io.Reader:
iter.Seq[T]is generic because the element type varies.io.Readeris interface-shaped because the act of reading bytes varies.
Both exist in modern Go. They are not in tension; they cover different problems.
container/list vs a generic Stack¶
Pre-1.18, container/list was Go's built-in doubly-linked list. Its API:
type List struct { ... }
type Element struct { Value interface{}; ... }
l := list.New()
l.PushBack(42)
v := l.Front().Value.(int) // type assertion required
Every Value is interface{}. Callers assert. The package is discouraged today — the godoc literally says "consider whether a slice would be simpler". Why?
- Boxing on every push.
42becomes a heap-allocatedinterface{}. - Type assertion on every read. The compiler cannot help; runtime panics if you got it wrong.
- Slice operations are usually fine. Most Go programs use slices, not linked lists.
Post-1.18, a generic linked list is trivially:
type Node[T any] struct {
v T
prev, next *Node[T]
}
type List[T any] struct {
head, tail *Node[T]
n int
}
No boxing, no assertions. A library author releasing a new linked list today would not use interface{}.
What did the stdlib team do?¶
Almost nothing. container/list is still there. It is not generic. The team chose stability over modernization. New code is told to use slices or write its own typed linked list. This decision is deliberate:
- Migrating
container/listto generics is a breaking change. - The package was never popular.
- Investing in
slicesandmapswas a higher-impact use of stdlib effort.
A pragmatic library author follows the same logic: migrate when the win is real, not when fashion demands it.
Backwards-compatible migrations¶
When a library decides to introduce generics, three migration paths exist:
Path 1 — Parallel API, same package¶
Add the generic version alongside the interface version:
// Old
func Sort(data Interface) { ... }
// New (Go 1.21+)
func SortSlice[T cmp.Ordered](s []T) { ... }
Pros: no module bump. Cons: API surface grows.
Path 2 — Parallel package¶
Put the generic version in a sister package:
This is what the stdlib did with slices (vs sort). The two coexist; new code imports the new package.
Path 3 — New major version¶
Release /v2 with a generic API. Keep /v1 alive for old callers:
// v1
import "github.com/hashicorp/golang-lru"
cache, _ := lru.New(128) // returns interface{}
// v2
import "github.com/hashicorp/golang-lru/v2"
cache, _ := lru.New[string, *User](128) // returns *User
Pros: clean break, no old API noise. Cons: every consumer must change imports.
Choosing a path¶
| Project size | Recommended path |
|---|---|
| Stdlib | Parallel package |
| Big external library | New major version |
| Small library | Parallel API in same package |
The wrong choice is often modifying the existing API in place. That breaks the world for no real gain.
The Hashicorp pattern¶
Hashicorp's golang-lru migration is the textbook example of generic adoption:
- Release v2 with generics. New module path:
github.com/hashicorp/golang-lru/v2. - Keep v1 alive. Same module, no breaking changes.
- Document migration. README and code comments point users to v2.
- No magic. v1 still uses
interface{}. v2 uses[K comparable, V any].
// v1 — old API
cache, _ := lru.New(128)
cache.Add("user_id", 42)
v, _ := cache.Get("user_id")
id := v.(int) // assertion
// v2 — generic API
cache, _ := lru.New[string, int](128)
cache.Add("user_id", 42)
id, _ := cache.Get("user_id") // already typed
Library authors publishing post-1.18 generic-friendly versions follow this pattern. It is the safest migration: clear semver story, no silent breakage, easy rollback.
What about the interface vs generic question itself?¶
golang-lru migrated from interface{} (which was a stand-in for generics) to actual generics. The library was always conceptually generic — every cache holds one type of value. The interface form was just the pre-1.18 workaround.
When a library is conceptually interface-shaped (different implementations behind one name — like io.Reader), it does not migrate at all. It stays an interface forever.
Designing for two generations of users¶
A library author in 2026 must serve:
- Users on Go 1.21+ who expect generic APIs.
- Users on Go 1.18-1.20 who can use generics but lack
slices/maps/cmp. - Users still on Go 1.17 (rare but real in regulated industries).
A practical approach:
- Set
go.modto the lowest version your callers tolerate. - Add generics conservatively. Each generic function constrains your minimum Go version.
- For widely used libraries, keep a non-generic fallback for users who cannot upgrade.
- Document the Go version your library requires in the README.
For internal libraries, this is much less of a concern — your team controls the Go version. The hard cases are public modules with thousands of unknown consumers.
Lessons from the standard library¶
The stdlib's adoption of generics is instructive:
| Package | Status | Why |
|---|---|---|
slices, maps, cmp | New, generic | Same body for many types |
sync/atomic | Generic Pointer[T] | Type-safe wrapper |
sync | OnceValue[T], mostly generic | Same body for many types |
io | Stays interface | Behaviour varies per implementation |
net/http | Stays interface | Plugin / handler model |
database/sql | Stays interface | Driver abstraction |
container/list | Stays interface (discouraged) | Migration cost not worth it |
sort | Both — sort and slices.Sort | Stability + new ergonomic API |
errors | Stays interface | Polymorphic error types |
iter | Generic Seq[T], Seq2[K, V] | Same body, many element types |
The pattern is consistent: shape-uniform → generic; behaviour-varying → interface; expensive to migrate → leave alone.
A library author copying these decisions cannot go far wrong.
Summary¶
The professional view of generics vs interfaces is strategic, not tactical:
- Both belong in the same toolbox. A library uses both at different layers.
- Migrate from
interface{}to generics when the body is uniform. That is the historical case where the interface was a workaround. - Do not migrate from real interfaces (
io.Reader,error,http.Handler) to generics. They are interface-shaped by nature. - Add the generic API alongside the interface API. Do not break callers.
- Use the Hashicorp
/v2pattern for major migrations of widely used libraries. - Keep one Go version above your stated minimum of headroom in your CI matrix.
The biggest professional lesson: the choice between generics and interfaces is rarely a fight. Most libraries use both. The interesting question is which layer of the library uses which tool, and how the public API stays stable across Go versions while internals modernize.
Move on to specification.md to see how the Go spec frames the two tools as type-set descriptors.