Generics
Generics (Go 1.18+) let you write functions and types parameterized by
the types they work with. Before generics, stuff like "generic
min" or a reusable "stack" required interface{} + runtime
type assertions. Now the compiler can check everything statically.
Learning objectives
- Declare generic functions with type parameters.
- Constrain type parameters via interfaces (including unions).
- Define generic structs and methods.
- Use the
comparablebuilt-in constraint. - Know when generics aren't worth the complexity.
Type parameters
func PrintSlice[T any](s []T) {
for _, v := range s {
fmt.Println(v)
}
}
PrintSlice([]int{1, 2, 3})
PrintSlice([]string{"a", "b"})
[T any] declares a type parameter T
with constraint any (accepts every type). Inside the body,
T behaves like a regular type.
Constraints
A constraint is any interface. Methods required by the interface are available on the type parameter:
type Stringer interface { String() string }
func JoinStrings[T Stringer](xs []T, sep string) string {
parts := make([]string, len(xs))
for i, x := range xs {
parts[i] = x.String() // allowed because T: Stringer
}
return strings.Join(parts, sep)
}
Union constraints
You can express "one of these types" with |:
type Number interface {
~int | ~int64 | ~float64 // | = union; ~ = "any type whose underlying type is"
}
func Sum[T Number](xs []T) T {
var s T
for _, x := range xs { s += x }
return s
}
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.5, 2.5})) // 4.0
The ~ ("tilde") lets custom types like
type Cents int also satisfy the constraint. Otherwise
only the exact listed types would.
The comparable built-in
comparable is a predeclared constraint for any type
that supports == and !=:
func Contains[T comparable](xs []T, target T) bool {
for _, x := range xs {
if x == target { return true }
}
return false
}
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
}
s := &Stack[int]{}
s.Push(1); s.Push(2)
v, _ := s.Pop()
fmt.Println(v) // 2
Generic methods
Methods on a generic type automatically see its type parameters. You can't add extra type parameters on a method that the type doesn't already declare. This is a deliberate design choice.
Type inference
Most of the time you don't need to specify the type argument; the compiler infers it from the arguments:
PrintSlice([]int{1, 2, 3}) // T inferred as int
PrintSlice[int]([]int{1, 2, 3}) // explicit: rarely needed
When to use generics (and when not to)
Good cases:
- Container types:
Set[T],Queue[T],LinkedList[T]. - Utility functions over slices/maps:
Map,Filter,Reduce. - Libraries that need to work across multiple related types.
Bad cases:
- Code that handles a single type. Just use that type.
- Interface-based polymorphism that already works well. Don't rewrite
io.Readerusage to be generic. - Clever constraints that sacrifice readability. Generics can make code harder to read.
slices and
maps packages are good examples of generics well-used.
Check your understanding
Practice exercises
Generic Map function
Write Map[T, U any](xs []T, f func(T) U) []U that applies f to each element of xs and returns a new slice.
Show solution
func Map[T, U any](xs []T, f func(T) U) []U {
out := make([]U, len(xs))
for i, x := range xs {
out[i] = f(x)
}
return out
}
nums := []int{1, 2, 3}
strs := Map(nums, func(n int) string { return fmt.Sprintf("n=%d", n) })
fmt.Println(strs) // [n=1 n=2 n=3]
Further reading
- Tutorial: Getting started with generics
- An introduction to generics
- slices package and maps package: stdlib generics
Type-safe polymorphism achieved.