Generic Data Structures — Professional Level¶
Table of Contents¶
- The stdlib
container/*packages container/heapvs a generic Heap[T]container/listvs a generic LinkedList[T]container/ringvs a generic RingBuffer[T]- When to ship a generic library
- When to use stdlib
- Library API design checklist
- Real-world generic container libraries
- Summary
The stdlib container/* packages¶
Go ships three container packages from the pre-generics era:
| Package | What it gives you | API style |
|---|---|---|
container/heap | Priority queue | Interface-based, requires user wrapper |
container/list | Doubly linked list | Operates on any-typed elements |
container/ring | Circular list | Operates on any-typed elements |
All three are frozen in the post-generics era — the Go team chose not to genericize them. Why? Backwards compatibility, plus uncertainty about the right shape for a generic API.
For new code, you have three options:
- Use the stdlib container — accept the
anyboxing and assertions - Write your own generic container — full type safety, more code to maintain
- Use a community library — type safety, external dependency
A senior engineer chooses among these consciously.
container/heap vs a generic Heap[T]¶
The stdlib API¶
type Interface interface {
sort.Interface
Push(x any)
Pop() any
}
// Using it requires a wrapper type
type IntHeap []int
func (h IntHeap) Len() int { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *IntHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *IntHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}
// Now use heap.Push, heap.Pop, etc.
h := &IntHeap{2, 1, 5}
heap.Init(h)
heap.Push(h, 3)
v := heap.Pop(h).(int) // type assertion required
Boilerplate per element type: - 5 method definitions - A type assertion on every Pop - Boxing on every Push (x any)
The generic alternative¶
type Heap[T any] struct {
data []T
less func(a, b T) bool
}
// Push, Pop, up, down — see senior.md
h := NewHeap[int](cmp.Less[int])
h.Push(2); h.Push(1); h.Push(5)
v, _ := h.Pop() // v is int — no assertion
Trade-off matrix¶
| Aspect | container/heap | Generic Heap[T] |
|---|---|---|
| Boilerplate | High | Low |
| Type safety | Runtime assertions | Compile-time |
| Boxing | Yes (any) | No |
| Familiarity | Universal | New to most users |
| Performance (numeric) | Slower | Faster |
| Performance (pointer T) | Comparable | Comparable |
Recommendation: for new code that will be touched by multiple engineers, ship a generic heap. For one-off internal scripts, container/heap is fine because the boilerplate is contained.
Why the stdlib didn't genericize¶
The Go team had three concerns:
- Compat: the existing API has many users; changing it risks breakage.
- Right shape: is the comparator a constraint (
cmp.Ordered), a function, or an interface? The community has not converged. - Subjective taste: the stdlib team prefers minimal API surface; a fully generic heap with multiple comparator styles bloats it.
A third-party generic heap can experiment freely — and several do (see "Real-world generic container libraries" below).
container/list vs a generic LinkedList[T]¶
The stdlib API¶
import "container/list"
l := list.New()
l.PushBack(42)
l.PushBack("hello") // also accepted!
for e := l.Front(); e != nil; e = e.Next() {
fmt.Println(e.Value) // any — assert to use
}
The Element.Value field is any. You can mix types, which is rarely what you want, and you must assert on every read.
The generic alternative¶
type LinkedList[T any] struct{ /* see middle.md */ }
l := &LinkedList[int]{}
l.PushBack(42)
// l.PushBack("hello") // ❌ does not compile
l.ForEach(func(v int) { fmt.Println(v) })
Compile-time enforcement of element type. No assertions.
When container/list still wins¶
- Sorted insertion via
MoveBefore/MoveAfteris built in. - The element type can change mid-list if you really need that (rare, usually a code smell).
- Existing code already uses it — adding a parallel generic version is overkill.
For new code, prefer a generic linked list every time. Iteration with Go 1.23 iter.Seq[T] makes the generic version even more ergonomic than the stdlib.
container/ring vs a generic RingBuffer[T]¶
container/ring is a circular list (not a ring buffer for queueing). Each element holds an any value. Use cases include:
- Fixed-size cyclic structures
- Round-robin selection
- Cyclic redo buffers
The generic alternative¶
type Ring[T any] struct {
data []T
pos int
}
func NewRing[T any](size int) *Ring[T] {
return &Ring[T]{data: make([]T, size)}
}
func (r *Ring[T]) Set(v T) { r.data[r.pos] = v }
func (r *Ring[T]) Next() { r.pos = (r.pos + 1) % len(r.data) }
func (r *Ring[T]) Value() T { return r.data[r.pos] }
Same shape, type-safe.
Practical guidance¶
container/ring is rarely used in modern Go code. For ring-buffer queue semantics, see the RingQueue[T] from middle.md. For cyclic iteration, a small generic struct is just as clean as the stdlib.
When to ship a generic library¶
You have a generic data structure inside your project. Should you publish it as a library?
Ship if¶
- It is a primitive every Go programmer needs (LRU cache, set, ordered map).
- The API has stabilised over at least three real users.
- You can commit to maintenance — bug fixes, Go version updates, godoc.
- No suitable library exists — check
pkg.go.devfirst. - Your team uses it across multiple repos — extracting it pays off.
Do not ship if¶
- It is internal to one product. Keep it in
internal/. - It is one method shy of
slices/maps— propose adding it to stdlib instead. - It mixes business logic with the container. Decouple first.
- You are still iterating on the API. Wait until you would be embarrassed by the next change.
How to ship¶
The Hashicorp model is the gold standard:
- Module path with explicit major version (
github.com/you/lib/v2). - Generic-only API, Go 1.21+ (for stdlib
slices/maps/cmp). - README with a 5-line "before/after" comparison.
- Examples for the two or three most common use cases.
- Benchmarks against stdlib equivalents.
When to use stdlib¶
Sometimes the boring choice is right.
Use container/heap when¶
- You are already importing it
- The wrapper boilerplate is not in a hot path
- Backwards compatibility with Go 1.17 matters
Use container/list when¶
- You want
Elementreferences that survive across operations - You need
MoveBefore/MoveAfter - You truly want
any-typed mixed elements (rare, suspicious)
Use container/ring when¶
- You need a circular list with explicit
Next/Prev - You want no allocations during rotation
Default to generic for new code¶
For greenfield code in Go 1.21+, generic versions are simpler at the call site, faster on numeric types, and type-safe. The stdlib container/* packages are now mostly legacy — usable, but rarely the best tool.
Library API design checklist¶
Before publishing a generic container library:
- Pick one comparator style (
cmp.Ordered, function, both) and document it. - Constructor returns a pointer (
*MyContainer[T]). - Mutating methods use pointer receivers; pure functions are free functions.
-
(T, bool)returns for "may not exist" — not panics. - No method-level type parameters (Go forbids them).
- Iteration:
ToSlice,ForEach, optionallyiter.Seq[T](build-tagged for 1.23+). - Document concurrency: "not safe for concurrent use" or "safe for concurrent reads".
- Benchmarks against stdlib and against
interface{}-based equivalents. - At least one type test (
TestStack_Strings) and one interface test (TestStack_Pointers). - Examples for each type-parameter shape (numeric, string, struct).
- godoc with type parameter explanation in the package doc.
- Semantic version
v1.0.0only after API has been stable for at least one release.
Real-world generic container libraries¶
A non-exhaustive list, current as of 2026:
| Library | Coverage | Notes |
|---|---|---|
golang.org/x/exp/maps | Map utilities | Many functions promoted to stdlib maps in 1.21 |
golang.org/x/exp/slices | Slice utilities | Promoted to stdlib slices in 1.21 |
golang.org/x/exp/constraints | Integer, Float, etc. | Most users only need cmp.Ordered from stdlib now |
hashicorp/golang-lru/v2 | LRU, ARC, 2Q caches | Production-grade, used in Vault and Terraform |
deckarep/golang-set/v2 | Set with rich methods | Most polished generic set library |
samber/lo | Lodash-style helpers | Big surface, opinionated |
elliotchance/orderedmap/v2 | Ordered map | Maintains insertion order |
puzpuzpuz/xsync/v3 | Concurrent maps and counters | Lock-free, generic |
gammazero/deque | Fast double-ended queue | Generic since 2022 |
Most projects pick two or three of these and write the rest in-house. Watch out for libraries that have not updated their generics API since 1.18 — they often miss cmp.Ordered and use a custom Ordered constraint.
What is still missing¶
As of 2026, the Go ecosystem has no clear winner for:
- Generic concurrent priority queues
- Generic skip lists
- Generic persistent (immutable) trees and lists
- Generic on-disk B-trees
Each has multiple competing libraries with different APIs. If your team needs one, expect to evaluate three options and pick the smallest one with a known maintainer.
Notes on samber/lo¶
samber/lo ports many JavaScript/Lodash idioms to Go generics:
import "github.com/samber/lo"
doubled := lo.Map([]int{1, 2, 3}, func(v int, _ int) int { return v * 2 })
Some teams love it; others find it un-Go-idiomatic. A common compromise: use it in application code where ergonomics dominate, avoid it in library code where small dependencies matter.
Summary¶
The professional view of generic data structures has three layers:
- Stdlib
container/*— still works, still useful, increasingly legacy. Use it when its API fits your problem and the boxing/assertions are not in a hot path. - Hand-written generics — for primitives that match your domain. The cost is maintenance; the benefit is full control.
- Community libraries —
hashicorp/golang-lru/v2,deckarep/golang-set/v2, etc. Use them when they save real code and the maintainer is reliable.
The migration from container/* to generic equivalents is not a stampede — it is a gradual replacement as old code is rewritten. New code should default to generic. Old code stays on the stdlib until there is a real reason to change.
If you are about to ship your own generic container library, the checklist above is your friend. If you are about to use someone else's, look at maintenance cadence, godoc coverage, and benchmark numbers before importing.
The biggest practical lesson: generics did not eliminate container/*; they added a parallel layer. Knowing both is part of what makes a Go engineer professional in the 1.18+ world.
Move on to specification.md for the formal grammar of generic type declarations.