Generics: Type Parameters Without the Guilt

Type parameters on functions and types, the constraint system (any, comparable, and the ~ underlying-type element), type inference that lets you omit the type argument, and — honestly — when generics are the wrong tool. Compiled and run against Go 1.26.5.

Go shipped without generics for its first decade, and the language did fine. That history matters, because it tells you generics arrived as a considered addition, not a founding feature, and the way to use them well is to remember that the language worked without them. Type parameters landed in Go 1.18 to solve one specific pain: writing the same typed container or algorithm over and over, once per element type, or throwing away type safety with interface{} and casting on the way out. That is the problem generics solve, and it is a narrower problem than “everything.” This chapter teaches the mechanics and then, just as carefully, teaches when to leave them alone. Everything here is compiled and run against Go 1.26.5.

Type parameters on functions

A generic function declares one or more type parameters in square brackets before its value parameters. Inside the function, those type parameters are ordinary types you can use in signatures and bodies:

import "cmp"

// Min returns the smaller of a and b, for any ordered type.
func Min[T cmp.Ordered](a, b T) T {
	if a < b {
		return a
	}
	return b
}

[T cmp.Ordered] reads as “for any type T that satisfies cmp.Ordered.” cmp.Ordered is a constraint from the standard library’s cmp package: it admits exactly the types the < operator works on (integers, floats, strings). The rest of the signature uses T like any concrete type. One definition now works for every ordered type, and — this is the point — the compiler still type-checks each call. There is no boxing, no interface{}, no runtime cast.

Type inference: you usually omit the type argument

You can spell the type argument out (Min[int](10, 4)), but you rarely need to. Go infers T from the values you pass:

func main() {
	fmt.Println(Min(3, 7))            // inferred: T = int
	fmt.Println(Min("pear", "apple")) // inferred: T = string
	fmt.Println(Min(2.5, 1.5))        // inferred: T = float64
	fmt.Println(Min[int](10, 4))      // explicit type argument
}
$ go run .
3
apple
1.5
4

"apple" sorts before "pear", so Min returns it — the same code path a moment after it returned an integer. Inference is why generic Go reads almost like non-generic Go at the call site: the brackets are declared once, at the definition, and disappear everywhere the function is used.

Constraints: what a type parameter is allowed to be

A type parameter is only as capable as its constraint says. Inside Min, you can use < only because cmp.Ordered promises it. Try to use an operation the constraint doesn’t guarantee and the code won’t compile. That is the whole idea: a constraint is the contract that tells the compiler which operations are legal on T.

Two constraints are built into the language:

  • any is the constraint that admits every type. It is a new spelling of interface{}, and it means “no requirements” — you can store and pass a T, but you can’t compare, add, or index it, because nothing is promised.
  • comparable admits every type usable with == and !=. You need it whenever a type parameter becomes a map key or gets equality-checked.

For anything richer, you write a constraint interface. A constraint interface can list ordinary methods (the same interfaces you already use for behavior), or it can list a set of permitted types with a type set:

// Number is a constraint: any type whose underlying type is one of these.
// The ~ means "and every named type built on it," not just the type itself.
type Number interface {
	~int | ~int64 | ~float64
}

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

The | unions the allowed types; the ~ is the load-bearing detail. ~int means “any type whose underlying type is int,” not just int itself. That matters the moment your program uses named types — and idiomatic Go uses a lot of them (type Celsius float64, type UserID int64). Without ~, a constraint of plain int | float64 would reject Celsius, and your generic function would be useless on exactly the domain types you defined for clarity. With ~float64, Celsius is admitted:

// Celsius has underlying type float64, so ~float64 admits it.
type Celsius float64

func main() {
	fmt.Println(Sum([]int{1, 2, 3, 4}))
	fmt.Println(Sum([]float64{1.5, 2.5}))
	fmt.Println(Sum([]Celsius{20.0, 1.5, -0.5}))
}
$ go run .
10
4
21

Note var total T in Sum: even a type parameter has a zero value, and var total T gives you the right one for whatever T turns out to be. For the numeric types here it is 0, the correct seed for a sum.

Type parameters on types

Types take type parameters too, which is how you build a typed container — the original motivation. Here is a stack that holds any element type without giving up type safety:

// Stack is a last-in, first-out container of any element type.
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
	if len(s.items) == 0 {
		return zero, false
	}
	last := s.items[len(s.items)-1]
	s.items = s.items[:len(s.items)-1]
	return last, true
}

func (s *Stack[T]) Len() int { return len(s.items) }

The methods carry the receiver’s type parameter: *Stack[T], not *Stack. Inside Pop, var zero T builds the correct zero value to return alongside the false — the same comma-ok shape you already use for map lookups and channel receives. A Stack[int] and a Stack[string] are distinct types, each fully checked:

func main() {
	var nums Stack[int]
	nums.Push(1)
	nums.Push(2)
	nums.Push(3)
	for nums.Len() > 0 {
		v, _ := nums.Pop()
		fmt.Print(v, " ")
	}
	fmt.Println()

	var words Stack[string]
	words.Push("go")
	words.Push("generics")
	v, ok := words.Pop()
	fmt.Println(v, ok)

	_, ok = (&Stack[float64]{}).Pop()
	fmt.Println("pop empty ok:", ok)
}
$ go run .
3 2 1 
generics true
pop empty ok: false

Push an int onto words and it won’t compile. That is the entire value proposition over a []interface{} stack: the mistakes are caught at build time, and no cast is needed on the way out.

Map and Filter: two type parameters

Algorithms over collections are the other natural fit. Map needs two type parameters, because it can change the element type — take a []T, produce a []U:

// Map applies f to every element, producing a new slice of a possibly new type.
func Map[T, U any](xs []T, f func(T) U) []U {
	out := make([]U, len(xs))
	for i, x := range xs {
		out[i] = f(x)
	}
	return out
}

// Filter keeps the elements for which keep returns true.
func Filter[T any](xs []T, keep func(T) bool) []T {
	var out []T
	for _, x := range xs {
		if keep(x) {
			out = append(out, x)
		}
	}
	return out
}
func main() {
	nums := []int{1, 2, 3, 4, 5, 6}
	evens := Filter(nums, func(n int) bool { return n%2 == 0 })
	fmt.Println(evens)

	labels := Map(evens, func(n int) string { return fmt.Sprintf("#%d", n) })
	fmt.Println(labels)

	words := []string{"go", "is", "fun"}
	shouted := Map(words, strings.ToUpper)
	fmt.Println(shouted)
}
$ go run .
[2 4 6]
[#2 #4 #6]
[GO IS FUN]

Inference handles both parameters: from Map(evens, func(n int) string {...}) the compiler reads T = int from evens and U = string from the function’s result. You never write the brackets. The standard library ships these now in the slices and maps packages (slices.Sort, slices.Contains, maps.Keys), so before you write your own generic helper, check whether the standard library already has it.

An edge worth knowing: methods can’t add type parameters

A method may use its receiver’s type parameters, as Stack’s methods used T. What it may not do is introduce a new one of its own. There is no func (s Set[T]) Map[U any](f func(T) U) Set[U]; the compiler rejects a type parameter list on a method. This surprises people arriving from languages where a generic method on a non-generic type is routine, and it is a deliberate limitation, not an oversight — allowing it would complicate interface satisfaction and the compiler’s dispatch model in ways the Go team judged not worth the cost.

The practical consequence: when you need a “method” that ranges over its own extra type, write a free function with two type parameters instead. That is exactly why Map above is a package-level func Map[T, U any](...) and not a method on some collection type. The function form has all the power; the method form is the one with the restriction. Keep transformations that change the element type as functions, and reserve methods for operations that stay within the receiver’s own type parameters.

When not to use generics

Here is the part most tutorials skip. Go ran for ten years without type parameters, and most Go code still doesn’t need them. The reach-for-generics reflex from other languages is the thing to unlearn.

  • For behavior, use an interface, not a type parameter. If your function only needs to call .Read() or .String() on its argument, an ordinary interface expresses that better and stays simpler. Generics are for when the concrete type must be preserved across the call — a container that gives back exactly what you put in, or a function whose input and output types are linked. If the type is immediately erased to “something with these methods,” that is an interface’s job, and it has been since Go 1.0.
  • Don’t parameterize for a single type. A “generic” function called at exactly one type is a concrete function wearing brackets. Write the concrete version; generalize only when a second real caller at a second real type shows up.
  • Prefer clarity to cleverness. A little duplication reads better than a constraint interface with four unions and a ~ you have to squint at. Generics can obscure as easily as they clarify; the win has to be real.

The honest rule: generics are the right tool for typed containers and algorithms that are genuinely used across many types — the Stack, the Map, the Min. They are the wrong tool as a default. When you are unsure, write the non-generic version first. It will compile just as fast, read just as clearly, and if a second type never arrives, you saved yourself the abstraction.

Final thoughts

Generics give you type parameters on functions and types, a constraint system built from any, comparable, and type-set interfaces where the ~ element reaches named types, and inference that keeps the call sites clean. They erase a real category of boilerplate and the interface{}-and-cast pattern it used to force. But they are an addition to a language that was already complete without them, and the best Go still treats them as a targeted tool for typed containers and multi-type algorithms — not a new default. Reach for them when the concrete type must survive the call; reach for an interface when only behavior matters.

Next: packages and modules — how real Go projects are organized, versioned, and shared.

Comments