49. Type parameters

📖 Reading · 11 min
💡 Every code box below is live — edit it and hit Run.

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:

  • T is a type parameter — a placeholder for a real type.
  • int | float64 | string is its constraint — the set of types allowed.
  • Inside the function, T is 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 instantiationFirst[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.