Go from the ground up

Generics

Writing one function for many types without giving up the types: type parameters, constraints that say what T can do, and knowing when a plain interface is still the better answer.

Part 11 of 12

Advanced15 min readBack to Courses

The problem generics solve

Before generics, a function that summed a list had to choose: one version for whole numbers, one for decimals, or take a vague type and give up all checking. Generics add the missing piece — a function over any type, with the type still known to the compiler at every call.

The syntax puts the type parameter in square brackets: func First[T any](items []T) T. T is a name for "some type", with any as its constraint — the promise it must keep. The function is written once; the compiler checks each call and generates the code for the concrete type.

Constraints say what T can do

A constraint is an interface, and an interface is a list of methods — so a type parameter is checked exactly like any other interface value. [T fmt.Stringer] accepts anything with a String() string method, and inside the function only those methods may be called.

Number types have no methods, so the language allows a constraint to be a union instead: interface{ ~int | ~float64 } accepts either type, and the tilde means "and any type whose underlying type is this one" — a named type built on int qualifies. comparable is the built-in constraint for types that support equality, which is what a map key or a comparison needs.

Types too, and knowing when to stop

Type parameters work on named types as well: type Stack[T any] struct { items []T } is a stack of whatever you put in it, with the same methods for every element type. Reading Stack[string] tells you everything the old empty-interface version could not.

The honest advice is to use generics last. If the code calls one method, an interface parameter is simpler and says more. If the code is a slice of steps that does not care about the element type, generics are the thing — and in between, a couple of concrete functions may beat both.

One constraint, three callers

package main

import "fmt"

type Number interface {
	~int | ~int64 | ~float64
}

func Sum[T Number](values []T) T {
	var total T
	for _, value := range values {
		total += value
	}
	return total
}

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) {
	if len(s.items) == 0 {
		var zero T
		return zero, false
	}
	last := len(s.items) - 1
	value := s.items[last]
	s.items = s.items[:last]
	return value, true
}

func main() {
	fmt.Println(Sum([]int{1, 2, 3}))
	fmt.Println(Sum([]float64{1.5, 2.5}))

	words := &Stack[string]{}
	words.Push("first")
	words.Push("second")
	if word, ok := words.Pop(); ok {
		fmt.Println("popped:", word)
	}
}

One function, two number types

Run it, then add a generic Min function with the same constraint, call it with a list of floats, and try calling Sum on a list of strings to read what the compiler says.

Output
Click "Run" to execute your code

var zero T is the idiom for a type parameter's zero value — the type is not known until the call, so the zero value is made rather than written as 0 or "".

What the compiler does with T

A type parameter is checked twice: once when the generic function is declared, against its constraint, and again when a call instantiates it with concrete types. Inside the function only the constraint's operations are legal — which is why total += value needs a numeric constraint and value == other needs comparable.

Type inference fills in the arguments you leave out. Sum([]int{1, 2, 3}) deduces T = int from the slice; Sum[int](...) is there for the rare call where a parameter does not mention T and the compiler has nothing to go on.

Go compiles a generic function once per shape rather than copying the source for every type. Values with the same underlying representation share an instantiation, and the constraint supplies the operations. The practical result is that generics cost little — but they are not templates: a type parameter cannot be a type switch case, and a method cannot add a type parameter of its own.

What instantiation means

Instantiation is the moment a generic function meets a concrete type. Sum([]int{1, 2, 3}) solves T = int, checks that int satisfies Number, and produces the code for that call. Sum([]float64{...}) does the same for float64. The source is written once; the checks happen at each call, and the compiler can still report the exact line where a type fails the constraint. A call that cannot infer T needs the explicit form, Sum[int](values), which is also a useful way to see what inference was doing.

var zero T works because every type has a zero value, even one the function has never seen. You cannot write 0 or "" for a type parameter, because T might be a struct or a slice; the declaration asks the compiler for the right empty value at instantiation.

Do not write [T any] and then assert T back to a concrete type — that is an interface with extra steps, and the assertion can fail at run time. Do not use == on T without comparable. And do not reach for a type parameter when the function only calls a method: an interface parameter says that better and accepts values that already exist.

Try these next

  • Write Filter[T any](items []T, keep func(T) bool) []T and call it on ints and strings.
  • Write Map[T, U any](items []T, f func(T) U) []U and use it to turn lengths into labels.
  • Add ~int to a constraint, then call the function with a named type Count int and with a string to read both errors.

Filter and map, once each

Run it, then filter the words by their first letter and map the result to their lengths. Add a `Map` call that changes the element type and watch inference do the work.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does the constraint any allow?