49. Type parameters
Before Go 1.18, writing "the same function for int and for string" meant
writing it twice, or writing it once with any and paying in type
assertions. Generics fixed that: a function can take a type as a
parameter.
The problem generics solve
package main
import "fmt"
func MaxInt(a, b int) int {
if a > b {
return a
}
return b
}
func MaxFloat(a, b float64) float64 {
if a > b {
return a
}
return b
}
func MaxString(a, b string) string {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(MaxInt(3, 7), MaxFloat(2.5, 1.5), MaxString("go", "c"))
}
Identical logic, three times, and a fourth if somebody adds int64. The
alternative — func Max(a, b any) any — compiles but is worse: no >
operator on any, and every caller has to assert the result back.
The generic version
package main
import "fmt"
func Max[T int | float64 | string](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(Max(3, 7))
fmt.Println(Max(2.5, 1.5))
fmt.Println(Max("go", "c"))
}
The new part is [T int | float64 | string], the type parameter list,
between the function name and its arguments:
Tis a type parameter — a placeholder for a real type.int | float64 | stringis its constraint — the set of types allowed.- Inside the function,
Tis used exactly like a normal type.
The constraint is what makes a > b legal: every type in that set supports
>, so the compiler can verify the body once, for all of them.
Type inference
Notice the call sites: Max(3, 7), not Max[int](3, 7). Go infers T from
the arguments.
package main
import "fmt"
func First[T any](items []T) T {
var zero T
if len(items) == 0 {
return zero
}
return items[0]
}
func main() {
fmt.Println(First([]int{10, 20}))
fmt.Println(First([]string{"a", "b"}))
fmt.Println(First([]float64{}))
fmt.Println(First[int]([]int{1, 2}))
}
any as a constraint means "any type at all" — it's the same any you met
in the interfaces module, doing a second job here.
Two things worth noting:
var zero T is how you produce the zero value of an unknown type. You
can't write return nil or return 0, because T might be neither. This
line appears in almost every generic function that can fail.
Explicit instantiation — First[int](...) — is available whenever
inference can't work it out, or when you want to be explicit for a reader.
Multiple type parameters
package main
import (
"fmt"
"sort"
)
func Keys[K comparable, V any](m map[K]V) []K {
out := make([]K, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func Values[K comparable, V any](m map[K]V) []V {
out := make([]V, 0, len(m))
for _, v := range m {
out = append(out, v)
}
return out
}
func main() {
ages := map[string]int{"ada": 36, "grace": 45, "alan": 41}
names := Keys(ages)
sort.Strings(names)
fmt.Println(names)
years := Values(ages)
sort.Ints(years)
fmt.Println(years)
}
comparable is a built-in constraint meaning "supports == and !=" — and
it's exactly what a map key requires, so K comparable is how you say
"anything that can be a map key".
Both parameters are inferred from the single map[string]int argument.
Transforming between types
When the output type isn't in the arguments, inference needs help:
package main
import (
"fmt"
"strings"
)
func Map[T, U any](in []T, f func(T) U) []U {
out := make([]U, 0, len(in))
for _, v := range in {
out = append(out, f(v))
}
return out
}
func main() {
words := []string{"go", "rust", "zig"}
upper := Map[string, string](words, strings.ToUpper)
fmt.Println(upper)
lengths := Map[string, int](words, func(s string) int { return len(s) })
fmt.Println(lengths)
}
Map[string, int] spells out both type parameters. A compiled Go program can
usually infer U from the function you pass and let you write Map(words,
...); the interpreter running these boxes needs it spelled out, and being
explicit is never wrong.
Filter, Reduce and friends
package main
import "fmt"
func Filter[T any](in []T, keep func(T) bool) []T {
var out []T
for _, v := range in {
if keep(v) {
out = append(out, v)
}
}
return out
}
func Contains[T comparable](in []T, want T) bool {
for _, v := range in {
if v == want {
return true
}
}
return false
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6}
fmt.Println(Filter(nums, func(n int) bool { return n%2 == 0 }))
words := []string{"go", "rust", "c"}
fmt.Println(Filter(words, func(s string) bool { return len(s) > 1 }))
fmt.Println(Contains(nums, 4), Contains(words, "java"))
}
Filter needs only one type parameter, so inference handles it. Contains
needs comparable rather than any, because it uses ==.
Note that the constraint is the contract: any gets you nothing but
storage and copying — no ==, no <, no arithmetic. Ask for exactly the
capability your body uses, and no more.
What generics are not
Two clarifications that save confusion:
They are not templates. Go compiles a generic function once per group of types with the same shape (roughly: one version for pointer-like types, one per distinct value layout) — not once per instantiation like C++. Compile times stay sane; the trade is that generic code can be marginally slower than a hand-written specialisation.
They are not dynamic. Everything is checked at compile time. There is no
runtime type parameter, and you can't switch on T — if you need runtime
type behaviour, that's still interfaces and type switches.
Your turn
Write a generic Sum that adds up a slice of int or float64:
10
7.5
package main
import "fmt"
// write Sum[T int | float64] here
func main() {
fmt.Println(Sum([]int{1, 2, 3, 4}))
fmt.Println(Sum([]float64{2.5, 5}))
}
package main
import "fmt"
func Sum[T int | float64](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(Sum([]int{1, 2, 3, 4}))
fmt.Println(Sum([]float64{2.5, 5}))
}
Next: naming and reusing those constraints.