Learn Go Series (#16) - Generics: Type Parameters, Constraints, and Inference
Learn Go Series (#16) - Generics: Type Parameters, Constraints, and Inference
What will I learn
- What type parameters are, and how to write a function that works for many types with full type safety;
- Constraints:
any,comparable, the standardcmp.Ordered, and custom type-set constraints; - Type inference, so you rarely have to spell out the type arguments;
- Generic types -- a
Stack[T]that holds any element type -- and methods on them; - The
~(approximation) token for constraints that also admit named types; - When generics are the right tool, and the very real cases where an interface or plain code is better.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Go distribution (1.27 or newer, from go.dev/dl) -- tested against Go 1.27;
- Episodes 1-15, especially interfaces (episode 9) and slices (episode 5);
- The ambition to learn Go programming.
Difficulty
- Intermediate
Curriculum (of the Learn Go Series):
- Learn Go Series (#1) - Introduction to Go
- Learn Go Series (#2) - Variables, Types, Constants, and iota
- Learn Go Series (#3) - Functions and First-Class Functions
- Learn Go Series (#4) - Control Flow: if, switch, and the Only Loop
- Learn Go Series (#5) - Arrays, Slices, and Slice Internals
- Learn Go Series (#6) - Maps and Sets
- Learn Go Series (#7) - Strings, Runes, and Unicode
- Learn Go Series (#8) - Structs, Methods, and Receivers
- Learn Go Series (#9) - Interfaces and Implicit Satisfaction
- Learn Go Series (#10) - Pointers, Memory, and Escape Analysis
- Learn Go Series (#11) - Error Handling the Go Way
- Learn Go Series (#12) - Packages, Modules, and Project Layout
- Learn Go Series (#13) - Goroutines and Channels: the Fundamentals
- Learn Go Series (#14) - select and the sync Toolbox
- Learn Go Series (#15) - context: Cancellation, Deadlines, and Request-Scoped Values
- Learn Go Series (#16) - Generics: Type Parameters, Constraints, and Inference (this post)
Learn Go Series (#16) - Generics: Type Parameters, Constraints, and Inference
For most of its life Go had no generics, and people wrote the same Map or Contains once per type, or reached for any and lost type safety at the door. Since Go 1.18 (early 2022) the language has type parameters: you write a function or type once, parameterised over the types it works with, and the compiler checks it and specialises it for each use. That word "deliberately" matters here. Generics were debated inside the Go team for the better part of a decade -- not because nobody wanted them, but because the team refused to bolt on the sprawling, hard-to-read version other languages ended up with. What finally shipped is small, careful, and slightly boring on purpose, which is exactly the Go way. They solve real duplication, but they are emphatically not the answer to everything, and knowing when not to reach for them is as much a part of the skill as the syntax. First, last episode's exercises.
Solutions to Episode 15 Exercises
Exercise 1 -- cancel the losers on the first result. Each worker also selects on ctx.Done():
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, name string, delay time.Duration, out chan<- string) {
select {
case <-time.After(delay):
select {
case out <- name:
case <-ctx.Done():
}
case <-ctx.Done(): // cancelled before finishing: stop early
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
out := make(chan string, 3)
go worker(ctx, "fast", 10*time.Millisecond, out)
go worker(ctx, "medium", 50*time.Millisecond, out)
go worker(ctx, "slow", 100*time.Millisecond, out)
first := <-out
cancel() // the medium and slow workers stop instead of finishing
fmt.Println("first result:", first) // fast
}
The nested select on the send matters: after cancel(), the loser goroutines must not block forever trying to send into a channel nobody is reading, so they select the send against ctx.Done() too. This is the cooperative-cancellation habit from episode 15, applied to both halves of the worker.
Exercise 2 -- a deadline met, and one missed:
package main
import (
"context"
"fmt"
"time"
)
func process(ctx context.Context, work time.Duration) error {
select {
case <-time.After(work):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx1, cancel1 := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel1()
fmt.Println("generous:", process(ctx1, 10*time.Millisecond)) //
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel2()
fmt.Println("tight:", process(ctx2, 100*time.Millisecond)) // context deadline exceeded
}
Exercise 3 -- thread a request ID down the chain:
package main
import (
"context"
"fmt"
)
type ctxKey string
const idKey ctxKey = "id"
func inner(ctx context.Context) {
fmt.Println("inner sees:", ctx.Value(idKey))
}
func outer(ctx context.Context) {
fmt.Println("outer sees:", ctx.Value(idKey))
inner(ctx)
}
func main() {
ctx := context.WithValue(context.Background(), idKey, "req-7")
outer(ctx)
}
The private ctxKey type is the whole point of exercise 3: the value survives being passed down through outer into inner because context values propagate down the tree, and the unexported key type keeps other packages from colliding with your "id" slot. Now, generics.
Type parameters: one function, many types
A type parameter is a placeholder type declared in square brackets before the ordinary parameters. Here Map is parameterised over an input element type T and an output type U, both constrained by any (meaning "any type at all"), so the same function transforms a slice of anything into a slice of anything:
package main
import "fmt"
// Map applies f to each element, returning a new slice. T, U are type parameters.
func Map[T, U any](s []T, f func(T) U) []U {
out := make([]U, len(s))
for i, v := range s {
out[i] = f(v)
}
return out
}
func main() {
nums := []int{1, 2, 3}
doubled := Map(nums, func(n int) int { return n * 2 })
labels := Map(nums, func(n int) string { return fmt.Sprintf("#%d", n) })
fmt.Println(doubled, labels) // [2 4 6] [#1 #2 #3]
}
Before generics, you either wrote MapIntToInt, MapIntToString, and so on until your fingers fell off, or you used []any and gave up type safety -- shoving everything through empty interfaces and clawing it back with type assertions at every step. Now one Map is fully type-checked for every use. Notice we never wrote the type arguments at the call site -- Map(nums, ...) -- because Go infers them from the arguments, which we return to shortly.
It helps to be precise about the mental model, because "generics" carries a lot of baggage from other languages. The square-bracket list [T, U any] is a type parameter list, and any there is not a wildcard -- it is a constraint, the contract that says what you are allowed to do with a value of that type inside the body. Every type parameter has a constraint; any just happens to be the most permissive one, which is why a body constrained by any can do so little (you cannot +, ==, or < an any, because not every type supports those). When you call Map(nums, ...), the compiler instantiates the function -- it works out that T is int and U is int (or string) and produces a version specialised to those types. That is the key difference from the old []any approach: there is no boxing, no runtime type assertion, no interface indirection on the hot path. The checking happens at compile time and the specialised code runs as if you had hand-written MapIntToString yourself. You get the reuse of the generic form and the speed and safety of the concrete form, which is precisely the trade generics were added to make.
Constraints: comparable and cmp.Ordered
any allows any type, but then you can only do things that work on any type -- you cannot use ==, <, or +, because not every type supports them. A constraint limits T to types that do. comparable is the built-in constraint for types you can compare with ==, which is exactly what a Contains needs:
package main
import "fmt"
// comparable: T must support == and !=
func Contains[T comparable](s []T, target T) bool {
for _, v := range s {
if v == target {
return true
}
}
return false
}
func main() {
fmt.Println(Contains([]int{1, 2, 3}, 2)) // true
fmt.Println(Contains([]string{"a", "b"}, "z")) // false
}
For ordering (<, >), the standard library gives you cmp.Ordered in the cmp package -- the constraint of all types that support the ordering operators (integers, floats, strings). That is precisely what a generic Max needs:
package main
import (
"cmp"
"fmt"
)
// cmp.Ordered: T supports (numbers and strings)
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(Max(3, 7)) // 7
fmt.Println(Max("apple", "pear")) // pear
fmt.Println(Max(2.5, 1.5)) // 2.5
}
cmp.Ordered (and the cmp.Compare/cmp.Less helpers alongside it) is the standard way to write ordered generic code without hand-rolling a numeric constraint. There is a small history lesson buried in that one import that is worth knowing. When generics first landed in 1.18, the ordering constraint lived in an experimental package (golang.org/x/exp/constraints) that you had to pull in separately; only in Go 1.21 did cmp.Ordered graduate into the standard library where it belongs. So if you read older blog posts that import "constraints" and reach for constraints.Ordered, you are looking at the pre-1.21 spelling -- the modern, batteries-included answer is cmp.Ordered. And here is a genuinely useful "when NOT to" foreshadow: Go 1.21 also added min and max as built-in functions for exactly the ordered case, so max(3, 7) now works with no generics and no import at all. Writing your own Max is a fine teaching exercise, but in real code you would just call the builtin -- a first taste of the lesson that closes this episode. The slices and maps packages in the standard library are built on exactly these constraints, and are well worth a read once this clicks.
Custom constraints with type sets
A constraint is just an interface -- and if you remember episode 9, that connection is the elegant part: the same interface mechanism you already know for behaviour ("a type with a Read method") is reused for type sets ("one of these concrete types"). An interface used as a constraint can list a type set: the concrete types allowed. The ~ token means "this underlying type and any named type based on it", so a constraint admits your type Celsius float64 as well as plain float64. Here is a numeric constraint for a generic Sum:
package main
import "fmt"
// Number is a constraint: the type set of these numeric kinds.
// ~ means "or any named type whose underlying type is this".
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](s []T) T {
var total T // the zero value of whatever T is
for _, v := range s {
total += v // allowed: every type in the set supports +
}
return total
}
func main() {
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.5, 2.5})) // 4
}
Because every type in Number's set supports +, the body can use + on a T. Listing the kinds you allow is how you unlock the operators you need -- the compiler will only let you use an operator inside the body if every type in the set supports it, which is why the type set is the thing that grants you +.
The ~ is subtle but genuinely important, so let me slow down on it. Cast your mind back to the difference between a named type and its underlying type: when you write type Celsius float64, Celsius is a brand-new type whose underlying type is float64. Without the ~, a constraint written as int | int64 | float64 would match only those three exact types -- hand it a Celsius and the compiler refuses, because Celsius is not literally float64, even though it behaves identically and supports the same +. Writing ~float64 instead says "float64, or any named type whose underlying type is float64", which lets Celsius, Meters, Kelvin and friends all satisfy the constraint. In practice you almost always want the ~ version, because the whole reason people define named numeric types is to get a distinct type that still does arithmetic -- and a generic function that rejected all of them would be uselessly strict. The mental shorthand: bare float64 in a type set is "exactly this type"; ~float64 is "this kind of thing". Reach for the tilde by default.
Generic types: a Stack of anything
Types can have type parameters too, not just functions. A Stack[T] holds elements of type T, and its methods use T throughout. Note the receiver is written *Stack[T], and inside a method var zero T gives you the zero value of whatever T turns out to be -- the clean way to return "nothing" from a generic Pop:
package main
import "fmt"
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 // zero value of T, whatever it is
if len(s.items) == 0 {
return zero, false
}
top := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return top, true
}
func main() {
var s Stack[string] // a stack of strings
s.Push("a")
s.Push("b")
v, ok := s.Pop()
fmt.Println(v, ok) // b true
}
Stack[string] and Stack[int] are distinct types the compiler generates from one definition, each fully type-checked -- no any, no type assertions on the way out. The var zero T idiom is the generic-code answer to a problem we met all the way back with zero values: you cannot write a literal like 0 or "" when you do not yet know whether T is a number, a string, or a struct, but var zero T always gives you the right zero for whatever T becomes, and pairing it with the (T, bool) return is the idiomatic "empty result" signal (the same comma-ok shape maps and channels use).
Two limitations are worth naming now so they do not surprise you later. First, the type parameter belongs to the type, not to individual methods -- Go does not allow you to add extra type parameters on a method (func (s *Stack[T]) MapTo[U any](...) is a compile error). If you need a second type in the operation, it has to be a free function like our Map, not a method. That is a deliberate restriction the Go team kept to kep the type system tractable, and it occasionally forces a helper function where you wanted a fluent method chain. Second, every distinct instantiation is real generated code, so Stack[int], Stack[string], and Stack[MyStruct] each cost a little compiled size -- rarely something to worry about, but it is why generics are "monomorphised specialisation", not free abstraction. Generic containers (stacks, queues, trees, sets) are one of the clearest wins for the feature, and building a small library of them is one of the most satisfying ways to internalise all of this.
Type inference, and when NOT to use generics
You have already seen inference at work: Map(nums, ...), Max(3, 7), Sum([]int{...}) -- no explicit [int] needed, because the compiler works the type arguments out from the values you passed. You can write them explicitly (Max[int](3, 7)) when inference cannot manage it -- typically when a type parameter appears only in the return type and not in any argument, so there is nothing for the compiler to infer from -- but in everyday code you almost never have to. Inference is what keeps generic Go readable instead of turning every call into an angle-bracket (well, square-bracket) soup.
Now the honest counterweight, and this is the part I want you to remember longer than the syntax. Generics are not free, and they are not always the right call. Reach for them when you have genuine duplication over types -- containers, and algorithms like Map/Filter/Sum that are identical bar the element type. Do not reach for them when an ordinary interface models the behaviour better: if you only need "something with a Write method", io.Writer is clearer, older, and more idiomatic than a type parameter, and it does not tie the caller's hands. And do not add a type parameter for a function used with exactly one concrete type -- that is complexity with no payoff, a costume with nobody inside it. Having said that, the trap that catches enthusiastic people is the opposite of the old duplication problem: reaching for a type parameter speculatively, because the function might one day be used with another type. Let a second real use case, not your imagination, be the thing that pushes you toward a generic. The Go proverb applies with full force here -- prefer the simplest thing that works, and remember that generics were added to remove duplication you actually have, not to let you show off a type set you might need. Overusing them makes code harder to read, which is the exact opposite of why they exist. When you are unsure, write the concrete version first; the generic version is a cheap refactor later, and you will know a lot more about the right shape once you have two real callers instead of one imagined one.
The same idea in Python
Python is dynamically typed, so "generic" code is just... normal code -- a function works on anything with the right operations, checked at run time. Python's optional typing generics (TypeVar, Generic) exist only to help a static type checker like mypy or pyright, not the interpreter, which happily ignores them:
from typing import TypeVar, Callable
T = TypeVar("T")
U = TypeVar("U")
def map_list(items: list[T], f: Callable[[T], U]) -> list[U]:
return [f(x) for x in items]
print(map_list([1, 2, 3], lambda n: n * 2)) # [2, 4, 6]
Python gets flexibility for free but defers all type errors to run time (or to an optional, separately-run checker that nothing forces you to heed). Pass a list[str] to something expecting numbers and Python will merrily run until the + blows up somewhere deep in the call, at runtime, in production, on a Friday. Go's generics give you the same reuse with the errors caught at compile time and no run-time cost from type assertions -- the recurring Go trade of a little more ceremony up front for a lot more certainty later. It is the same philosophy we saw with interfaces and with context: Go would rather make you say slightly more and then guarantee it, than let you say less and hope. Neither approach is "right" in the abstract -- Python's looseness is a joy for scripts and notebooks -- but for the long-lived, many-hands services Go targets, moving the error from a 3 a.m. pager to a red squiggle in your editor is worth a few square brackets.
Exercises
A generic Filter. Write
Filter[T any](s []T, keep func(T) bool) []Treturning a new slice of the elements for whichkeepis true. Use it to keep the even numbers from an[]intand the non-empty strings from a[]string.A generic Min with cmp.Ordered. Write
Min[T cmp.Ordered](s []T) (T, bool)returning the smallest element and a bool that is false for an empty slice. Test it on integers and on strings, and handle the empty case with thevar zero Tidiom.A generic Set. Build a
Set[T comparable]type backed by amap[T]struct{}, withAdd(T),Contains(T) bool, andLen() intmethods. Show it working with both anintset and astringset, and explain in a comment why the element type must becomparable.
What we learned
- Type parameters (in
[...]before the ordinary parameters) let one function or type work for many types, fully type-checked and specialised at compile time -- no per-type duplication, noany, no runtime cost; - A constraint limits a type parameter to types that support the operations you use:
any(anything),comparable(==), and the standardcmp.Ordered(<,>) -- note Go 1.21 also gave us built-inmin/max; - Constraints are interfaces that can list a type set; the
~token also admits named types based on those underlying types, unlocking operators like+; - Generic types (a
Stack[T]) parameterise a data structure;var zero Tyields the zero value of the parameter,Stack[int]/Stack[string]are distinct compiled types, and methods cannot add their own type parameters; - Type inference figures out the type arguments from the values, so you rarely write them -- which keeps generic code readable;
- Generics are for genuine type duplication (containers,
Map/Filter/Sum); prefer an interface for behaviour, and do not add a type parameter used with only one type.
Next episode we make our code trustworthy: testing -- Go's built-in testing package, table-driven tests (the idiom you will use most), subtests, and t.Helper, all with zero third-party dependencies. See you there.
Bedankt en tot de volgende! ;-)