Go Generics
Overview
Generics (introduced in Go 1.18) let you write functions and types that work across multiple types without duplicating code. The key idea: type parameters — placeholders for concrete types supplied at call/instantiation time, constrained by interfaces.
Generic Functions
Type parameters are declared in square brackets before the regular parameter list. The constraint (any, comparable, or a custom interface) restricts which types are valid.
// Without generics — one function per type
func SumInts(s []int) int { ... }
func SumFloats(s []float64) float64 { ... }
// With generics — one function for all numeric types
func Sum[T int | float64](s []T) T {
var total T
for _, v := range s {
total += v
}
return total
}
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.1, 2.2})) // 3.3
Go infers the type argument from the arguments — you rarely need to specify it explicitly. But you can:
Sum[int]([]int{1, 2, 3}) // explicit — usually unnecessary
Constraints
A constraint is an interface that defines what a type parameter must support. Built-in constraints:
| Constraint | Meaning |
|---|---|
any | Alias for interface{} — any type |
comparable | Types that support == and != (maps, slices excluded) |
// comparable constraint — needed for map keys or equality checks
func Contains[T comparable](s []T, target T) bool {
for _, v := range s {
if v == target {
return true
}
}
return false
}
fmt.Println(Contains([]string{"a", "b", "c"}, "b")) // true
fmt.Println(Contains([]int{1, 2, 3}, 5)) // false
Type-Set Interfaces — A New Form of Interface
Go 1.18 didn't just add generics — it also extended the meaning of interfaces. Traditional interfaces are method-based: a type satisfies the interface if it implements the required methods. This still works exactly as before.
// Traditional — method-based (works anywhere)
type Stringer interface {
String() string
}
But constraints need more than methods — to use < on a type parameter T, the compiler needs to know T is ordered. You can't express that with methods alone. So Go introduced type-set interfaces: instead of listing methods, they list the exact concrete (or underlying) types that are permitted.
// Type-set interface — lists allowed types
type Ordered interface {
int | int8 | int16 | int32 | int64 |
uint | uint8 | uint16 | uint32 | uint64 |
float32 | float64 |
string
}
func Min[T Ordered](a, b T) T {
if a < b {
return a
}
return b
}
fmt.Println(Min(3, 5)) // 3
fmt.Println(Min("b", "a")) // a
Because the compiler knows every type in Ordered supports <, it allows a < b inside the function.
The key distinction:
| Interface type | Where it can be used | What it expresses |
|---|---|---|
| Method-based | Anywhere (variables, params, returns) | Behaviour — what a type can do |
| Type-set | Only as a constraint on type parameters | Identity — which concrete types are allowed |
You cannot use a type-set interface as a regular variable type (
var x Orderedis a compile error) — they exist solely to constrain generics.
Custom Constraints with constraints Package / Union Types
You can define a constraint interface using a union of types with |:
type Number interface {
int | int8 | int16 | int32 | int64 |
float32 | float64
}
func Min[T Number](a, b T) T {
if a < b {
return a
}
return b
}
The golang.org/x/exp/constraints package provides ready-made constraints: constraints.Ordered, constraints.Integer, constraints.Float, etc.
~T — Underlying Type Constraint
~T means "any type whose underlying type is T". This lets your generic function work with custom types built on top of primitives.
type Celsius float64
type Fahrenheit float64
type Temperature interface {
~float64 // matches float64, Celsius, Fahrenheit, etc.
}
func Average[T Temperature](temps []T) T {
var sum T
for _, t := range temps {
sum += t
}
return sum / T(len(temps))
}
fmt.Println(Average([]Celsius{20, 25, 30})) // 25
Without ~, Celsius would not satisfy float64 — only the exact float64 type would.
Generic Types (Structs)
Type parameters work on struct definitions too:
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
}
top := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return top, true
}
s := Stack[int]{}
s.Push(1)
s.Push(2)
v, _ := s.Pop()
fmt.Println(v) // 2
Generic Interfaces (Parametric Constraints)
Interfaces can have type parameters too — not just structs and functions. This lets you define a contract that is itself parameterised, so different implementations can work with different concrete types while sharing the same interface shape.
type item interface {
Title() string
Availability() bool
}
// library is a generic interface — parameterised by the item type it lends
type library[I item] interface {
Lend(I)
}
Here library[I item] says: "a library that lends things of type I, where I must satisfy the item interface." Two completely separate implementations can satisfy library with different item types:
type bookLibrary struct{ booksLent []book }
func (bl *bookLibrary) Lend(b book) { bl.booksLent = append(bl.booksLent, b) }
type audioLibrary struct{ audioBooksLent []audioBook }
func (al *audioLibrary) Lend(ab audioBook) { al.audioBooksLent = append(al.audioBooksLent, ab) }
A generic function can then accept any library[I] without caring which concrete type it is:
func lendItems[I item](l library[I], items []I) {
for _, it := range items {
l.Lend(it)
}
}
// Works for both
lendItems[book](&bl, []book{ ... })
lendItems[audioBook](&al, []audioBook{ ... })
The type parameter on the interface ([I item]) propagates into the generic function — the compiler verifies that l and items agree on the same concrete type I at compile time.
This pattern is useful when you have a family of types (books, audio books, e-books) that all satisfy a shared behaviour interface (
item), but each needs its own strongly-typed container.
Multiple Type Parameters
A function or type can have more than one type parameter:
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
doubled := Map([]int{1, 2, 3}, func(n int) int { return n * 2 })
strs := Map([]int{1, 2, 3}, func(n int) string { return fmt.Sprintf("%d", n) })
fmt.Println(doubled) // [2 4 6]
fmt.Println(strs) // [1 2 3]
When to Use Generics
| Use generics when | Avoid generics when |
|---|---|
| Writing reusable data structures (Stack, Queue, Set) | The function only works for one concrete type |
Utility functions over slices/maps (Map, Filter, Reduce) | The logic relies on runtime behaviour (reflection, type switches) |
| Avoiding code duplication across numeric types | Premature abstraction — start concrete, generalise when needed |
Gotchas
| Gotcha | Detail |
|---|---|
any doesn't support operators | +, <, == on any won't compile — use a proper constraint |
| Type inference limits | Inference works for function args, not always for return types — may need explicit type args |
| No generic methods | Methods cannot introduce new type parameters — only the receiver's type params are in scope |
| Generics ≠ faster | Generic code is not necessarily faster than interface-based code; benchmark before optimising |
| Zero value of type param | Use var zero T to get the zero value of a type parameter |