Free Handbook · Every example compiled & verified

Generics

Write one function or type that works for many types: type parameters, constraints like comparable and cmp.Ordered, ~T, generic data structures and the slices and maps packages.

0 / 134 lessons🔥 0 day streak
ShareXLinkedIn

Module 10 · what you'll be able to do

  • Declare generic functions and types with type parameters, and let the compiler infer them
  • Pick the right constraint: any, comparable, cmp.Ordered or your own interface with ~T terms
  • Build generic data structures such as Stack[T] and Set[K], and helpers like Map, Filter and Reduce
  • Use the standard slices and maps packages instead of rewriting loops
  • Recognise when an interface or plain code is better than generics, and read the common generics compile errors
01

Type parameters on functions

Before Go 1.18 you had two choices for code that should work on several types: copy it once per type (maxInt, maxFloat, ...) or accept any and give up type safety. Generics add a third: a function declares type parameters in square brackets before its normal parameters, func Max[T int | float64 | string](a, b T) T, and the compiler checks every call.

The part after the name, int | float64 | string, is the constraint: the set of types T may be, and therefore what you may do with a T (here, compare with >). Usually you do not write Max[int](3, 7) — the compiler infers T from the arguments. Write it explicitly when inference cannot work, or to force a type: Max[float64](3, 7.5) turns the untyped 3 into a float64.

gomain.go
package main

import "fmt"

// Without generics: one function per type.
func maxInt(a, b int) int {
	if a > b {
		return a
	}
	return b
}

// With generics: T is a type parameter, constrained to ordered types.
func Max[T int | float64 | string](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	fmt.Println(maxInt(3, 7))
	fmt.Println(Max(3, 7))            // T inferred as int
	fmt.Println(Max(2.5, 1.5))        // T inferred as float64
	fmt.Println(Max("go", "rust"))    // T inferred as string
	fmt.Println(Max[float64](3, 7.5)) // explicit: 3 becomes float64
}
Outputcompiled & run with real Go
7
7
2.5
rust
7.5
Your turn

Call Max(3, 7.5) without the explicit type argument. It compiles — work out which T the compiler picked from the two untyped constants, then check by printing with %T.

Instantiation
Supplying the type arguments — explicitly or by inference — is called instantiation. Max[int] is an ordinary, non-generic function func(int, int) int. Each call site is type-checked against its instantiation, so a mistake is a compile error, never a runtime panic.
Error you will hit

invalid operation: a > b (type parameter T cannot use operator >)

go
package main

import "fmt"

func Max[T any](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	fmt.Println(Max(3, 7))
}
# command-line-arguments
./main.go:6:5: invalid operation: a > b (type parameter T cannot use operator >)
Why the compiler said that

Inside a generic function you may only do what the constraint allows for every type in it. any includes structs, slices and functions, none of which support >, so the body is rejected — even though the only call uses ints.

The fix

Tighten the constraint to one that guarantees ordering: cmp.Ordered from the standard library.

go
package main

import (
	"cmp"
	"fmt"
)

func Max[T cmp.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	fmt.Println(Max(3, 7))
}
02

Constraints: any, comparable, cmp.Ordered and ~T

A constraint is an interface. Besides methods, a constraint interface can list types with | (a type set). Four constraints cover almost everything you will write:

ConstraintAllowsTypical use
anyevery type; you can only assign, pass and return valuescontainers: Stack[T], Map, Filter
comparable== and !=map keys, Index, Contains, sets
cmp.Ordered< <= > >= (integers, floats, strings)max/min, sorting, binary search
your own, e.g. ~int | ~float64the operators every listed type shares (+, *, ...)numeric helpers such as Sum

A tilde matters: float64 in a type set means exactly float64, while ~float64 means any type whose underlying type is float64. Programs are full of named types like type Celsius float64 or type UserID int64; without the tilde your generic function refuses them. cmp.Ordered is defined with tildes for exactly this reason.

gomain.go
package main

import (
	"cmp"
	"fmt"
)

// comparable: types that support == and != (needed for map keys too).
func Index[T comparable](items []T, target T) int {
	for i, v := range items {
		if v == target {
			return i
		}
	}
	return -1
}

// cmp.Ordered: every type that supports < <= > >=.
func Largest[T cmp.Ordered](items []T) T {
	best := items[0]
	for _, v := range items[1:] {
		if v > best {
			best = v
		}
	}
	return best
}

// A custom constraint. ~float64 means "float64 or any type whose underlying type is float64".
type Number interface {
	~int | ~int64 | ~float64
}

func Sum[T Number](nums []T) T {
	var total T // zero value of T
	for _, n := range nums {
		total += n
	}
	return total
}

type Celsius float64

func main() {
	fmt.Println(Index([]string{"a", "b", "c"}, "c"))
	fmt.Println(Index([]int{4, 5}, 9))
	fmt.Println(Largest([]float64{2.5, 9.1, 3}))
	fmt.Println(Largest([]string{"kiwi", "apple", "pear"}))
	fmt.Println(Sum([]int{1, 2, 3}))
	temps := []Celsius{20.5, 21, 19.5}
	fmt.Printf("%.1f %T\n", Sum(temps)/Celsius(len(temps)), Sum(temps))
}
Outputcompiled & run with real Go
2
-1
9.1
pear
6
20.3 main.Celsius

Sum returns the same type it was given: summing []Celsius gives a Celsius, not a bare float64.

Your turn

Add ~int32 to Number, declare type Cents int32, and sum a []Cents.

Error you will hit

Celsius does not satisfy Number (possibly missing ~ for float64 in Number)

go
package main

import "fmt"

type Number interface {
	int | int64 | float64
}

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

type Celsius float64

func main() {
	fmt.Println(Sum([]Celsius{20.5, 21}))
}
# command-line-arguments
./main.go:20:17: Celsius does not satisfy Number (possibly missing ~ for float64 in Number)
Why the compiler said that

Celsius is a distinct named type. The type set int | int64 | float64 contains only those three exact types. The compiler even guesses the fix in its message.

The fix

Use ~float64 (and ~int, ~int64) in the constraint so named types built on them are accepted.

go
package main

import "fmt"

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

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

type Celsius float64

func main() {
	fmt.Println(Sum([]Celsius{20.5, 21}))
}
Error you will hit

[]int does not satisfy comparable

go
package main

import "fmt"

func Index[T comparable](items []T, target T) int {
	for i, v := range items {
		if v == target {
			return i
		}
	}
	return -1
}

func main() {
	rows := [][]int{{1, 2}, {3, 4}}
	fmt.Println(Index(rows, []int{3, 4}))
}
# command-line-arguments
./main.go:16:19: []int does not satisfy comparable
Why the compiler said that

Inference made T = []int, but slices cannot be compared with == (Module 04), so []int is not in the comparable type set. The same rule stops slices, maps and functions from being map keys.

The fix

Compare the elements yourself. The standard library already has the helpers: slices.Equal for two slices, and slices.IndexFunc to search with your own test.

go
package main

import (
	"fmt"
	"slices"
)

func main() {
	rows := [][]int{{1, 2}, {3, 4}}
	i := slices.IndexFunc(rows, func(r []int) bool {
		return slices.Equal(r, []int{3, 4})
	})
	fmt.Println(i) // 1
}
03

Generic types: Stack[T], Set[K] and Pair[K, V]

Types can have type parameters too: type Stack[T any] struct { items []T }. Every use names the type argument — Stack[int], Stack[string] — and each is a separate concrete type. Methods repeat the parameter in the receiver, func (s *Stack[T]) Push(v T), but do not repeat the constraint.

A generic function often needs "nothing" of type T to return — for example Pop on an empty stack. var zero T gives the zero value of whatever T is: 0, "", nil. Pair it with a bool (the comma-ok pattern) so callers can tell "empty" from "held a zero".

gomain.go
package main

import "fmt"

// Stack is a generic type: T is fixed when you create one.
type Stack[T any] struct {
	items []T
}

func (s *Stack[T]) Push(v T) {
	s.items = append(s.items, v)
}

// Pop returns the top item and true, or the zero value and false when empty.
func (s *Stack[T]) Pop() (T, bool) {
	var zero T
	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 (s *Stack[T]) Len() int { return len(s.items) }

func main() {
	var nums Stack[int]
	nums.Push(1)
	nums.Push(2)
	nums.Push(3)
	var popped []int
	for nums.Len() > 0 {
		v, _ := nums.Pop()
		popped = append(popped, v)
	}
	fmt.Println(popped) // last in, first out

	words := &Stack[string]{}
	words.Push("hello")
	w, ok := words.Pop()
	fmt.Printf("%q %v\n", w, ok)
	w, ok = words.Pop()
	fmt.Printf("%q %v\n", w, ok) // zero value of string
}
Outputcompiled & run with real Go
[3 2 1]
"hello" true
"" false
Your turn

Add a Peek() (T, bool) method that returns the top item without removing it.

VisualizeA Stack[int], push by pushStep 1 / 7
package main
import "fmt"
// Stack is a generic type: T is fixed when you create one.
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
// Pop returns the top item and true, or the zero value and false when empty.
func (s *Stack[T]) Pop() (T, bool) {
var zero T
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 (s *Stack[T]) Len() int { return len(s.items) }
func main() {
var nums Stack[int]
nums.Push(1)
nums.Push(2)
nums.Push(3)
var popped []int
for nums.Len() > 0 {
v, _ := nums.Pop()
popped = append(popped, v)
}
fmt.Println(popped) // last in, first out
words := &Stack[string]{}
words.Push("hello")
w, ok := words.Pop()
fmt.Printf("%q %v\n", w, ok)
w, ok = words.Pop()
fmt.Printf("%q %v\n", w, ok) // zero value of string
}
Line 28

Stack[int] instantiates the type with T = int. Its zero value is ready to use: a nil slice.

Variables now
nums.items[]
All 7 steps as a table
StepLineWhat happenedVariables now
128Stack[int] instantiates the type with T = int. Its zero value is ready to use: a nil slice.nums.items = []
229Push appends. Pushing a string here would be a compile error.nums.items = [1]
331Two more pushes; the top is the last element.nums.items = [1 2 3]
434Pop takes the last element and shrinks the slice.v = 3 nums.items = [1 2]
535The loop repeats until Len() is 0.popped = [3 2 1] nums.items = []
637Last in, first out.
743Popping an empty Stack[string] returns var zero T — the empty string — and false.w = "" ok = false

Types can take several parameters, each with its own constraint. A set built on a map needs keys that are at least comparable; this one asks for cmp.Ordered so it can return members in sorted order — Go map iteration order is random, and sorting is how you make output repeatable.

gomain.go
package main

import (
	"cmp"
	"fmt"
	"slices"
)

// Set is a generic set built on a map. Map keys only need comparable;
// cmp.Ordered (which implies comparable) is used so Sorted can sort them.
type Set[K cmp.Ordered] struct {
	m map[K]struct{}
}

func NewSet[K cmp.Ordered](items ...K) *Set[K] {
	s := &Set[K]{m: make(map[K]struct{})}
	for _, v := range items {
		s.Add(v)
	}
	return s
}

func (s *Set[K]) Add(v K)      { s.m[v] = struct{}{} }
func (s *Set[K]) Has(v K) bool { _, ok := s.m[v]; return ok }
func (s *Set[K]) Len() int     { return len(s.m) }

// Sorted returns the members in order, so printing never depends on map order.
func (s *Set[K]) Sorted() []K {
	out := make([]K, 0, len(s.m))
	for k := range s.m {
		out = append(out, k)
	}
	slices.Sort(out)
	return out
}

// Pair holds two values of different types.
type Pair[K comparable, V any] struct {
	Key K
	Val V
}

func main() {
	tags := NewSet("go", "rust", "go", "java")
	fmt.Println(tags.Len(), tags.Has("go"), tags.Has("php"))
	fmt.Println(tags.Sorted())

	ids := NewSet(3, 1, 2, 3)
	fmt.Println(ids.Sorted())

	p := Pair[string, float64]{"pi", 3.14}
	fmt.Printf("%+v\n", p)
}
Outputcompiled & run with real Go
3 true false
[go java rust]
[1 2 3]
{Key:pi Val:3.14}
Your turn

Add func (s *Set[K]) Remove(v K) using the built-in delete, and a function Union[K cmp.Ordered](a, b *Set[K]) *Set[K].

Type parameters on methods
Methods use the type parameters declared on their type. For years Go did not allow a method to declare extra type parameters of its own (func (b Box[T]) Map[U any](...)) and you wrote a top-level function instead. The Go 1.27 toolchain used by this handbook accepts it; if your project targets an older Go version, keep such helpers as functions like Map in the next lesson.
04

Generic helpers: Map, Filter and Reduce

With two type parameters a function can transform one type into another: Map[T, U any] turns a []T into a []U. Because the helper is typed, the compiler checks the callback: passing a func(int) string to a []string is a compile error. Note that strings.ToUpper can be passed directly — it is already a func(string) string.

gomain.go
package main

import (
	"fmt"
	"strings"
)

func Map[T, U any](items []T, f func(T) U) []U {
	out := make([]U, 0, len(items))
	for _, v := range items {
		out = append(out, f(v))
	}
	return out
}

func Filter[T any](items []T, keep func(T) bool) []T {
	var out []T
	for _, v := range items {
		if keep(v) {
			out = append(out, v)
		}
	}
	return out
}

func Reduce[T, A any](items []T, start A, f func(A, T) A) A {
	acc := start
	for _, v := range items {
		acc = f(acc, v)
	}
	return acc
}

func main() {
	words := []string{"go", "generics", "are", "handy"}

	lengths := Map(words, func(s string) int { return len(s) })
	fmt.Println(lengths)

	long := Filter(words, func(s string) bool { return len(s) > 3 })
	fmt.Println(long)

	upper := Map(long, strings.ToUpper)
	fmt.Println(upper)

	total := Reduce(lengths, 0, func(acc, n int) int { return acc + n })
	fmt.Println("total letters:", total)
}
Outputcompiled & run with real Go
[2 8 3 5]
[generics handy]
[GENERICS HANDY]
total letters: 18
Your turn

Use Reduce to find the longest word: start with "" and keep whichever string is longer.

Go style still prefers loops
These helpers are good practice for writing generics, but idiomatic Go code usually just writes the for loop — it is as short, easier to debug and avoids a closure call per element. Reach for helpers when the same transformation appears in many places. For iterating lazily, Go 1.23 added iterators (iter.Seq) which the slices and maps packages use.
Error you will hit

cannot use generic function Map without instantiation

go
package main

import "fmt"

func Map[T, U any](items []T, f func(T) U) []U {
	out := make([]U, 0, len(items))
	for _, v := range items {
		out = append(out, f(v))
	}
	return out
}

func main() {
	double := Map
	fmt.Println(double([]int{1, 2}, func(n int) int { return n * 2 }))
}
# command-line-arguments
./main.go:14:12: cannot use generic function Map without instantiation
Why the compiler said that

A generic function is a template, not a value. Assigning it to a variable needs a concrete type, and there are no arguments here for the compiler to infer T and U from.

The fix

Instantiate it when you take its value: double := Map[int, int]. Calling Map directly with arguments needs no instantiation.

go
package main

import "fmt"

func Map[T, U any](items []T, f func(T) U) []U {
	out := make([]U, 0, len(items))
	for _, v := range items {
		out = append(out, f(v))
	}
	return out
}

func main() {
	double := Map[int, int]
	fmt.Println(double([]int{1, 2}, func(n int) int { return n * 2 }))
}
Error you will hit

in call to Zero, cannot infer T

go
package main

import "fmt"

func Zero[T any]() T {
	var z T
	return z
}

func main() {
	fmt.Println(Zero())
}
# command-line-arguments
./main.go:11:18: in call to Zero, cannot infer T (declared at ./main.go:5:11)
Why the compiler said that

Inference works from the arguments. Zero() has none, and Go never infers a type parameter from how the result is used.

The fix

Pass the type argument explicitly.

go
package main

import "fmt"

func Zero[T any]() T {
	var z T
	return z
}

func main() {
	fmt.Printf("%q\n", Zero[string]())
}
05

The slices and maps packages

The biggest everyday win from generics is in the standard library. The slices package (Go 1.21) works on a slice of any element type: Sort, Contains, Index, Max, Min, BinarySearch, Clone, Equal, Reverse, Compact and their ...Func variants. The maps package does the same for maps: Keys, Values, Clone, Equal, DeleteFunc.

gomain.go
package main

import (
	"fmt"
	"maps"
	"slices"
)

func main() {
	scores := []int{72, 95, 60, 88}
	fmt.Println(slices.Max(scores), slices.Min(scores))
	fmt.Println(slices.Contains(scores, 60), slices.Index(scores, 88))

	sorted := slices.Clone(scores) // copy, so scores is untouched
	slices.Sort(sorted)
	fmt.Println(scores, sorted)
	fmt.Println(slices.BinarySearch(sorted, 88))

	stock := map[string]int{"pear": 3, "apple": 5, "kiwi": 0}
	// Map order is random; sort the keys for stable output.
	keys := slices.Sorted(maps.Keys(stock))
	fmt.Println(keys)
	for _, k := range keys {
		fmt.Printf("%s=%d\n", k, stock[k])
	}

	backup := maps.Clone(stock)
	maps.DeleteFunc(stock, func(k string, v int) bool { return v == 0 })
	fmt.Println(len(stock), len(backup), maps.Equal(stock, backup))
}
Outputcompiled & run with real Go
95 60
true 3
[72 95 60 88] [60 72 88 95]
2 true
[apple kiwi pear]
apple=5
kiwi=0
pear=3
2 3 false

maps.Keys returns an iterator in random order; slices.Sorted collects it into a sorted slice — the standard way to loop over a map deterministically.

Your turn

Use slices.Reverse on sorted to print the scores highest first, and slices.Sorted(maps.Values(stock)) to print the stock counts in order.

Sort sorts in place
slices.Sort(s) reorders the slice you pass — and every other slice that shares its backing array (Module 04). slices.Clone first if the caller still needs the original order.
06

When not to use generics

Generics remove duplication when the code is the same for every type and only the element type changes: containers, slice and map utilities, algorithms like sorting and searching. They are the wrong tool when behaviour differs per type — that is what interfaces are for (Module 06). A rule of thumb from the Go team: if you find yourself writing a type switch inside a generic function, you wanted an interface.

gomain.go
package main

import (
	"fmt"
	"strings"
)

// Behaviour differs per type -> an interface, not a type parameter.
type Shape interface{ Area() float64 }

type Rect struct{ W, H float64 }
type Square struct{ S float64 }

func (r Rect) Area() float64   { return r.W * r.H }
func (s Square) Area() float64 { return s.S * s.S }

func totalArea(shapes []Shape) float64 {
	total := 0.0
	for _, s := range shapes {
		total += s.Area()
	}
	return total
}

// Same code for every element type -> a type parameter.
func Reverse[T any](s []T) []T {
	out := make([]T, len(s))
	for i, v := range s {
		out[len(s)-1-i] = v
	}
	return out
}

func main() {
	fmt.Println(totalArea([]Shape{Rect{2, 3}, Square{2}}))
	fmt.Println(Reverse([]int{1, 2, 3}))
	fmt.Println(strings.Join(Reverse([]string{"c", "b", "a"}), ""))
}
Outputcompiled & run with real Go
10
[3 2 1]
abc
Your turn

Add a Circle type to totalArea. Notice you did not touch Reverse — and adding a new element type to Reverse needs no new code either.

Use a type parameter when…

  • the same algorithm works for any element type
  • you want the result typed (no any and type assertions)
  • you are writing a container or a slice/map helper

Use an interface or plain code when…

  • each type needs its own implementation (Area, Write, String)
  • there is only one type today — do not generalise early
  • a method-based interface like io.Reader already fits
Type parameter
A placeholder for a type, declared in square brackets: func F[T any](x T).
Type argument
The concrete type substituted for a type parameter: the int in F[int].
Constraint
An interface that limits which types a type parameter accepts and which operations the body may use.
Type set
The set of types an interface allows, written with |, e.g. ~int | ~float64.
~T (underlying type)
Matches T and every named type defined as T, such as type Celsius float64.
comparable
Built-in constraint for types that support == and !=.
cmp.Ordered
Standard constraint for types that support <: integers, floats and strings (with ~).
Type inference
The compiler working out type arguments from the function's arguments so you can omit them.
Quick check

Given func Sum[T int | float64](xs []T) T and type Score int, what happens with Sum([]Score{1, 2})?

Quick check

Which constraint lets a generic function use its type parameter as a map key?

Frequently asked questions

When were generics added to Go?
Go 1.18 added type parameters. Go 1.21 followed with the generic slices, maps and cmp packages in the standard library, which is where most code benefits from them.
What is the difference between any and comparable in Go generics?
any accepts every type but only lets you assign, pass and return values. comparable accepts only types that support == and !=, so you can compare values and use them as map keys. Slices, maps and functions are not comparable.
What does the tilde (~) mean in a Go constraint?
~T matches T and every type whose underlying type is T. ~float64 accepts float64 and named types like type Celsius float64; plain float64 accepts only float64 itself.

Finish the Go handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.