A Practical Introduction to Go Generics
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
Generics arrived in Go 1.18, and Go 1.21 added cmp, slices and maps to the standard library. They are not a universal answer: they solve one implementation serving several types without giving up type safety.
Two shapes of the same problem
// 1) one function per type
func SumInts(xs []int) int { /* ... */ }
func SumFloats(xs []float64) float64 { /* ... */ } // and it keeps growing
// 2) any plus type assertions
func SumAny(xs []any) any {
total := 0
for _, x := range xs {
total += x.(int) // blows up at run time
}
return total
}
Generics turn the type into a compile-time parameter, so there is neither duplication nor a loss of checking.
The first generic function
func Sum[T int | float64](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.5, 2.5})) // 4
Tis a type parameter andint | float64is its constraint. The constraint is what makes+legal.- Calls rarely need
Sum[int](...); the compiler infersTfrom the argument. var total Tyields the type’s zero value — the only safe way to initialise aT.
Built-in constraints
func Contains[T comparable](xs []T, target T) bool {
for _, x := range xs {
if x == target {
return true
}
}
return false
}
any: no operations guaranteed. Usable for assignment, passing and little else.comparable: supports==/!=, and is what map keys require. Slices, maps and functions do not qualify.- Anything more (arithmetic, ordering) needs a constraint you write or import.
Custom constraints
type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
func Average[T Number](xs []T) float64 {
if len(xs) == 0 {
return 0
}
var sum T
for _, x := range xs {
sum += x
}
return float64(sum) / float64(len(xs))
}
~ includes defined types whose underlying type matches:
type Celsius float64
Average([]Celsius{20.5, 21.5}) // valid
Without ~, only the literal types are accepted and Celsius is rejected — the detail most people miss when writing constraints.
cmp.Ordered already covers everything orderable:
import "cmp"
func Max[T cmp.Ordered](xs []T) T {
m := xs[0]
for _, x := range xs[1:] {
if x > m {
m = x
}
}
return m
}
Constraints can require methods
A constraint is an interface, so it can demand behaviour:
type Stringer interface {
String() string
}
func Join[T Stringer](xs []T, sep string) string {
parts := make([]string, len(xs))
for i, x := range xs {
parts[i] = x.String()
}
return strings.Join(parts, sep)
}
Constraints compose:
type Key interface {
comparable
String() string
}
Generic types
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, true
}
var s Stack[string]
s.Push("a")
Note: methods cannot introduce new type parameters. func (s *Stack[T]) Map[U any](f func(T) U) is illegal. Write a free function instead:
func Map[T, U any](xs []T, f func(T) U) []U {
out := make([]U, 0, len(xs))
for _, x := range xs {
out = append(out, f(x))
}
return out
}
names := Map(users, func(u User) string { return u.Name })
Filter and Reduce work the same way. Before writing them, check slices — slices.Contains, slices.Index, slices.Sort and friends already exist, and maps.Keys / maps.Values cover the map cases.
Type inference
Sum([]int{1, 2}) // T = int
Map(users, func(u User) string { return u.Name }) // T = User, U = string
Inference fails when a type parameter only appears in the result:
func Zero[T any]() T { var z T; return z }
Zero[int]() // must be explicit
When not to use generics
- Only one type will ever call it. Write the concrete function; it reads better.
- Behaviour differs per type. Use an interface and polymorphism, not a pile of type switches.
- To save a few lines. Generic error messages get longer and the maintenance cost goes up.
- When you need dynamic types.
anyplus assertions, or reflection, is the honest tool (JSON handling, for example).
Rule of thumb: reach for generics when the same logic has been copied a third time.
Common mistakes
1. Constraint too loose to allow the operation
func Add[T any](a, b T) T { return a + b } // compile error: operator + not defined
any guarantees nothing. Constrain to Number or similar.
2. Forgetting ~
type UserID int64 does not satisfy an int64 constraint; ~int64 does.
3. Type-asserting on T
func Print[T any](v T) {
if s, ok := v.(string); ok { ... } // illegal: T is not an interface value
}
Needing a type switch is a sign the function should not be generic — or that T should be constrained to an interface and use its methods.
4. Generic methods
Go has no method-level type parameters. Only types and functions can take them.
5. Assuming nil
var zero T is not necessarily nil; for T = int it is 0. Use *T or (T, bool) when “unset” must be distinguishable.
6. Over-abstraction
A signature like Repository[T any, ID comparable, ...] makes readers solve equations. Past two type parameters, reconsider the design.
7. Ignoring the standard library
slices, maps and cmp cover sorting, searching, deduplication and comparison. Check them before writing your own.
Summary
- Type parameters let one implementation serve many types; the constraint decides which operations are legal.
anyallows nothing,comparableallows==,cmp.Orderedallows ordering; use~to include defined types.- Generic types can have methods, but methods cannot add type parameters.
- Initialise values with
var zero T; type assertions onTare not allowed. - Copy it twice, then generalise — and prefer the standard library.
Next: putting these pieces together in a command-line tool.