Generics
Type parameters arrived late to Go and the community uses them sparingly. Here is the everyday use, and why restraint is the right default.
Generics let a function work with several types without giving up type safety:
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
Max(3, 5) // 5, an int
Max(2.5, 1.5) // 2.5, a float64
Max("apple", "pear") // "pear", a string
Max(true, false) // compile error — bool is not Ordered[T cmp.Ordered] declares a type parameter T constrained to types that support < and >. You rarely pass it explicitly — it is inferred from the arguments.
Constraints#
A constraint is an interface describing what T must support. Go has a few built in:
any // no constraint at all
comparable // supports == and != (usable as a map key)
cmp.Ordered // supports < <= > >=And you can write your own as a union of types:
type Number interface {
~int | ~int64 | ~float32 | ~float64
}
func Sum[T Number](values []T) T {
var total T // the zero value of T
for _, v := range values {
total += v
}
return total
}
Sum([]int{1, 2, 3}) // 6
Sum([]float64{1.5, 2.5}) // 4.0The ~ means "any type whose underlying type is this", so a type Celsius float64 also satisfies it. Without ~, only float64 exactly would qualify.
A constraint can also require methods:
type Stringer interface {
~string | interface{ String() string }
}Generic types#
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T // how you produce a zero value of an unknown type
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}s := &Stack[string]{}
s.Push("a")
v, ok := s.Pop() // v is a stringvar zero T is the idiom for "the zero value of whatever T is" — you cannot write nil or 0, because you do not know which applies.
Note that methods cannot introduce new type parameters. A method can use the type's parameters, but func (s *Stack[T]) Map[U any](...) is not legal. That is a deliberate restriction and it rules out some functional patterns people expect.
What the standard library gives you#
Most of what people write generics for already exists:
import ("slices"; "maps"; "cmp")
slices.Contains(nums, 3)
slices.Index(nums, 3)
slices.Sort(nums)
slices.SortFunc(users, func(a, b User) int { return cmp.Compare(a.Age, b.Age) })
slices.Max(nums)
slices.Reverse(nums)
slices.Clone(nums)
slices.Equal(a, b)
maps.Keys(m) // an iterator
slices.Sorted(maps.Keys(m)) // sorted keys, one line
maps.Values(m)
cmp.Compare(a, b)
cmp.Or(a, b, c) // first non-zero value
min(a, b); max(a, b) // builtins since 1.21Before writing a generic helper, check whether slices or maps already has it. Generated Go frequently hand-rolls contains and map functions that have been in the standard library for years.
When not to use generics#
The community norm is restraint, and it is well founded. Reach for a type parameter only when all three are true:
- You genuinely need the same logic for several types.
- An interface with a method would not express it as well.
- The types must be related in a way
anyplus a type assertion cannot capture.
The common over-reaches:
// 1. A parameter used once — it is doing nothing
func Log[T any](v T) { fmt.Println(v) }
func Log(v any) { fmt.Println(v) } // just say any
// 2. Behaviour, not shape — an interface is the right tool
func Save[T Saveable](item T) error {}
func Save(item Saveable) error {} // simpler, and dynamic
// 3. One concrete type in practice
type UserCache[T any] struct{} // only ever UserCache[User]The rule of thumb: if the type parameter appears only once in the signature, delete it. It carries no information between the parameters and the return, which is the only thing it can usefully do.
The honest position
Generics solved a real problem — before them, slices.Contains had to be written per type or lose type safety. For everyday application code you will use the generic standard library constantly and write your own type parameters rarely. That ratio is correct, and generated Go tends to over-produce them.
Exercise#
package main
import "fmt"
func main() {
// 1. Write GroupBy[T any, K comparable](items []T, key func(T) K) map[K][]T
// 2. Write Filter[T any](items []T, keep func(T) bool) []T
// 3. Use both on a slice of structs, then check whether the standard
// library already covers what you wrote.
fmt.Println("start")
}Common questions#
Why did Go wait so long to add generics?#
Because the design has real costs — compile time, readability, and the risk of the type gymnastics that other languages accumulate. The team held out for a design that stayed simple, and the resulting restraint in how the community uses them is arguably part of that design succeeding.
Should I make my library generic?#
Only if callers genuinely need it with several types. A concrete API is easier to read, easier to document and produces better error messages. Generic signatures are the part of a library people struggle with most.
What is ~ in a constraint?#
It means "any type whose underlying type is this". ~int accepts both int and type Age int. Without the tilde, a named type based on int would not satisfy the constraint — which is almost never what you want.
Get the Go agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Go. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.