52. When to use generics — and when not to
Generics are the newest big feature in Go, which makes them the most over-applied. The Go team's own guidance is unusually blunt: write code, not types. Start concrete, and reach for a type parameter only when you have actual duplication in front of you.
Three cases where generics are right
1. Container types. A stack, queue, set, cache, tree or linked list is the textbook case: the logic is genuinely identical for every element type.
package main
import "fmt"
type Set[T comparable] struct {
items map[T]struct{}
}
func NewSet[T comparable](values []T) *Set[T] {
s := &Set[T]{items: make(map[T]struct{}, len(values))}
for _, v := range values {
s.Add(v)
}
return s
}
func (s *Set[T]) Add(v T) { s.items[v] = struct{}{} }
func (s *Set[T]) Has(v T) bool { _, ok := s.items[v]; return ok }
func (s *Set[T]) Len() int { return len(s.items) }
func main() {
langs := NewSet([]string{"go", "rust", "go", "zig"})
fmt.Println(langs.Len(), langs.Has("go"), langs.Has("java"))
ids := NewSet([]int{1, 2, 2, 3})
fmt.Println(ids.Len(), ids.Has(2))
}
Go still has no built-in set. This is the standard fix, and it's better than
map[string]bool copy-pasted five times.
(A compiled Go program would usually declare that constructor variadic —
NewSet[T comparable](values ...T), called as NewSet("go", "rust"). The
in-browser interpreter can't handle a variadic type parameter, so the box
above takes a slice instead.)
2. Slice and map helpers. Operations on any slice, where the element type never matters:
package main
import "fmt"
func Chunk[T any](items []T, size int) [][]T {
if size <= 0 {
return nil
}
var out [][]T
for i := 0; i < len(items); i += size {
end := i + size
if end > len(items) {
end = len(items)
}
out = append(out, items[i:end])
}
return out
}
func Reverse[T any](items []T) []T {
out := make([]T, len(items))
for i, v := range items {
out[len(items)-1-i] = v
}
return out
}
func main() {
fmt.Println(Chunk([]int{1, 2, 3, 4, 5}, 2))
fmt.Println(Chunk([]string{"a", "b", "c"}, 2))
fmt.Println(Reverse([]int{1, 2, 3}))
}
3. Removing a any-plus-assertion API. If your existing code takes any
and immediately asserts, generics are strictly better — same flexibility, but
the compiler checks it and the caller gets a real type back.
Where an interface is the better tool
This is the distinction that matters most:
package main
import (
"fmt"
"strings"
)
type Shape interface {
Area() float64
}
type Rect struct{ W, H float64 }
type Circle struct{ R float64 }
func (r Rect) Area() float64 { return r.W * r.H }
func (c Circle) Area() float64 { return 3.14159 * c.R * c.R }
func TotalArea(shapes []Shape) float64 {
total := 0.0
for _, s := range shapes {
total += s.Area()
}
return total
}
func main() {
shapes := []Shape{Rect{3, 4}, Circle{1}}
fmt.Printf("%.2f\n", TotalArea(shapes))
fmt.Println(strings.Repeat("-", 20))
}
TotalArea needs behaviour (Area()), and it needs a mixed slice of
different shapes. That's an interface, and a generic version would be worse —
[]Shape holding a Rect and a Circle is the requirement, and
[]T where T is one concrete type can't express it.
The test:
| you need | use |
|---|---|
| different types, same behaviour (methods) | an interface |
| different types, same code (logic doesn't care) | generics |
| a heterogeneous collection | an interface |
| a homogeneous container of one caller-chosen type | generics |
Where neither is right: just write the function
The most common misuse is generifying something used once:
// over-engineered — there is one caller and it passes []string
func ProcessItems[T any](items []T, fn func(T) error) error { ... }
// clearer, and easier to change later
func ProcessNames(names []string) error { ... }
Ask: do I have two or more concrete implementations right now? If the answer is no, the type parameter is speculation. Concrete code is easier to read, easier to debug, and takes ten seconds to generify later when the second caller actually arrives.
The real costs
Generics aren't free:
- Readability.
func Process[K comparable, V any, R Ordered](m map[K]V, f func(K, V) R) []Ris a puzzle. Concrete signatures are self-documenting. - Error messages. A constraint violation produces a much longer, less obvious compiler error than a plain type mismatch.
- A little runtime cost. Go doesn't specialise per instantiation the way C++ does, so generic code over value types can carry a small indirection overhead that a concrete function doesn't.
- Contagion. A generic type tends to force generics on everything that touches it.
A worked decision
Say you're writing a function to find the highest-scoring item.
package main
import "fmt"
type Player struct {
Name string
Score int
}
// Version 1: concrete. Start here.
func TopPlayer(players []Player) (Player, bool) {
if len(players) == 0 {
return Player{}, false
}
best := players[0]
for _, p := range players[1:] {
if p.Score > best.Score {
best = p
}
}
return best, true
}
// Version 2: generic, once a SECOND type genuinely needs it.
func TopBy[T any](items []T, score func(T) int) (T, bool) {
var zero T
if len(items) == 0 {
return zero, false
}
best := items[0]
for _, v := range items[1:] {
if score(v) > score(best) {
best = v
}
}
return best, true
}
type Team struct {
Name string
Points int
}
func main() {
players := []Player{{"Ada", 90}, {"Grace", 95}, {"Alan", 88}}
teams := []Team{{"Red", 12}, {"Blue", 30}}
p, _ := TopPlayer(players)
fmt.Println("top player:", p.Name)
p2, _ := TopBy(players, func(p Player) int { return p.Score })
t, _ := TopBy(teams, func(t Team) int { return t.Points })
fmt.Println("generic:", p2.Name, "and", t.Name)
}
Version 1 is right when there's one type. Version 2 earns its keep at the
moment Team shows up — and notice it needs a score function, which is
extra API surface the concrete version didn't need. That's the trade, made
visible.
The rule, one line
Write the concrete version first. Generify when you're copying it for the second time, with the third already in sight.
Your turn
Write a generic GroupBy that turns a slice into a map keyed by whatever the
key function returns:
even: [2 4 6]
odd: [1 3 5]
package main
import "fmt"
// write GroupBy[T any](items []T, key func(T) string) map[string][]T
func main() {
nums := []int{1, 2, 3, 4, 5, 6}
groups := GroupBy(nums, func(n int) string {
if n%2 == 0 {
return "even"
}
return "odd"
})
fmt.Println("even:", groups["even"])
fmt.Println("odd:", groups["odd"])
}
package main
import "fmt"
func GroupBy[T any](items []T, key func(T) string) map[string][]T {
out := make(map[string][]T)
for _, v := range items {
k := key(v)
out[k] = append(out[k], v)
}
return out
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6}
groups := GroupBy(nums, func(n int) string {
if n%2 == 0 {
return "even"
}
return "odd"
})
fmt.Println("even:", groups["even"])
fmt.Println("odd:", groups["odd"])
}
That's the language. The last three modules are about using it: the standard library, testing and tooling, and capstone projects.