Go Generics — A Comprehensive Guide
A comprehensive guide to Go generics: type parameters, constraints, and type inference.
Table of Contents
- Introduction
- History and Motivation
- Basic Syntax
- Type Parameters
- Type Constraints
- The
constraintsandcmpPackages - Type Sets and Interfaces as Constraints
- Generic Functions
- Generic Types (Structs)
- Generic Methods and Their Limitations
- Type Inference
- Instantiation
- The
anyType - The
comparableConstraint - Union Elements and the
~(Underlying Type) Operator - Generic Data Structures
- Standard Library Generic Packages
- Variadic Generics and Multiple Type Parameters
- Performance Considerations
- Common Pitfalls
- Best Practices
- Generics vs.
interface{}/anyvs. Code Generation - Real-World Examples
- Conclusion
Introduction
Generics were introduced in Go 1.18 (released March 2022), marking one of the largest changes to the Go language since its 1.0 release in 2012. Generics allow functions and types to operate on a range of different types while retaining full compile-time type safety, without resorting to interface{} (now any) and runtime type assertions.
Before generics, Go developers had three main workarounds:
- Writing separate functions for every type (code duplication)
- Using
interface{}and type assertions/switches (loses compile-time safety, adds runtime overhead) - Using code generation tools like
go generatewith templates (adds build complexity)
Generics solve this by letting you write a single implementation that is type-parameterized, checked by the compiler, and (in most cases) monomorphized or dictionary-passed at compile time for reasonable performance.
History and Motivation
The Go team explored generics design for many years. Key milestones:
- 2010: Early discussions about generics started almost immediately after Go’s public release.
- 2018–2019: The “type parameters” draft design was published, going through several iterations (including a rejected “contracts” proposal).
- June 2021: The finalized proposal was accepted for Go 1.18.
- March 2022: Go 1.18 shipped with generics, type parameters, constraints, and the
slices/mapsexperimental packages (golang.org/x/exp/slices,golang.org/x/exp/maps). - Go 1.21 (2023): The
slices,maps,cmp, andmin/maxbuiltins were promoted to the standard library.
The core motivation was to eliminate code duplication for container types and algorithms (e.g., a generic Stack[T], Map(), Filter(), Reduce()) while preserving Go’s philosophy of simplicity and fast compilation.
Basic Syntax
A generic function declares type parameters in square brackets right after the function name:
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
Breaking this down:
[T constraints.Ordered]— declares a type parameter namedT, constrained to types that satisfyconstraints.Ordered(i.e., support<,>, etc.)(a, b T)— the function parameters are both of typeT- The return type is also
T
Calling it:
result := Max[int](3, 7) // explicit instantiation
result2 := Max(3, 7) // implicit — compiler infers T = int
Type Parameters
Type parameters behave like regular parameters but represent types instead of values. They are declared inside square brackets, and each type parameter has:
- A name (by convention, a single uppercase letter like
T,K,V,E) - A constraint that limits which types can be substituted for it
func Print[T any](s []T) {
for _, v := range s {
fmt.Println(v)
}
}
Multiple type parameters are comma-separated:
func MapKeys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
Naming conventions commonly used in the Go ecosystem:
T— a generic “Type”K,V— Key and Value (for maps)E— Element (for slices)S— a Slice type parameter (e.g.,S ~[]E)
Type Constraints
A constraint is an interface type that specifies the set of permissible type arguments and the set of operations supported by values of that type. Constraints can be:
- Predeclared:
any(alias forinterface{}),comparable - Defined via interfaces with method sets (like regular Go interfaces)
- Defined via interfaces with type sets (using union
|and underlying-type~elements)
type Number interface {
int | int8 | int16 | int32 | int64 |
float32 | float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
Constraints can also require methods:
type Stringer interface {
String() string
}
func Join[T Stringer](items []T, sep string) string {
parts := make([]string, len(items))
for i, item := range items {
parts[i] = item.String()
}
return strings.Join(parts, sep)
}
Constraints can combine both method sets and type sets:
type OrderedStringer interface {
~int | ~string
String() string
}
The constraints and cmp Packages
Originally, the experimental golang.org/x/exp/constraints package provided common constraints like Ordered, Signed, Unsigned, Integer, Float, and Complex.
As of Go 1.21, the standard library’s cmp package provides:
package cmp
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 | ~string
}
func Compare[T Ordered](x, y T) int
func Less[T Ordered](x, y T) bool
Example usage:
import "cmp"
func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
Since Go 1.21, the builtins min and max are also available natively for ordered types, without needing generics at all:
x := min(3, 7) // 3
y := max(3.1, 7.2) // 7.2
Type Sets and Interfaces as Constraints
Since Go 1.18, interfaces can describe type sets, not just method sets. An interface used purely as a constraint may list:
- Union elements:
int | int64 | float64— the type argument must be one of these - Underlying type elements:
~int— the type argument’s underlying type must beint(this also permits defined types liketype MyInt int)
type Integer interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
Without the ~, only the exact type int would satisfy the constraint — a custom type like type UserID int would not match int directly, but it would match ~int.
This distinction matters a lot in practice:
type Celsius float64
type Number interface {
int | float64 // no ~
}
func Double[T Number](x T) T { return x * 2 }
// Double(Celsius(10)) // COMPILE ERROR: Celsius does not implement Number
vs.
type Number interface {
~int | ~float64 // with ~
}
func Double[T Number](x T) T { return x * 2 }
Double(Celsius(10)) // OK
Generic Functions
Generic functions are the most common use case. A few illustrative examples:
Filter:
func Filter[T any](s []T, keep func(T) bool) []T {
result := make([]T, 0, len(s))
for _, v := range s {
if keep(v) {
result = append(result, v)
}
}
return result
}
Map:
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
}
Reduce:
func Reduce[T, U any](s []T, init U, f func(U, T) U) U {
acc := init
for _, v := range s {
acc = f(acc, v)
}
return acc
}
Usage:
nums := []int{1, 2, 3, 4, 5}
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
squares := Map(nums, func(n int) int { return n * n })
sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })
Generic Types (Structs)
Structs, interfaces, and other type declarations can also be parameterized:
type Pair[K, V any] struct {
Key K
Value V
}
func NewPair[K, V any](key K, value V) Pair[K, V] {
return Pair[K, V]{Key: key, Value: value}
}
Generic types can be nested and composed:
type Tree[T any] struct {
Value T
Children []*Tree[T]
}
type Graph[T comparable] struct {
Nodes map[T]bool
Edges map[T][]T
}
When you use a generic type, you must supply the type arguments (unless they can be inferred from a constructor function, as in NewPair above):
p := Pair[string, int]{Key: "age", Value: 30}
Generic Methods and Their Limitations
Methods on generic types automatically inherit the type parameters of the receiver:
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
}
Important limitation: Go does not allow a method itself to introduce new type parameters beyond those of its receiver. This means you cannot write generic methods with their own independent type parameter (a common frustration compared to languages like Java or C#):
type Container[T any] struct {
value T
}
// This is NOT allowed:
// func (c Container[T]) MapTo[U any](f func(T) U) Container[U] { ... }
The workaround is to use a standalone generic function instead:
func MapContainer[T, U any](c Container[T], f func(T) U) Container[U] {
return Container[U]{value: f(c.value)}
}
This restriction exists because Go’s method sets and interface satisfaction rules become significantly more complex if methods can have their own type parameters — it’s an active topic of possible future language evolution but is not supported today.
Type Inference
The Go compiler can often infer type arguments so you don’t need to specify them explicitly. There are two main inference mechanisms:
- Function argument type inference: infers type parameters from the types of regular function arguments.
- Constraint type inference: infers a type parameter from another type parameter’s structural constraint (e.g., inferring an element type from a slice type constraint like
~[]E).
func First[T any](s []T) T {
return s[0]
}
x := First([]int{1, 2, 3}) // T inferred as int, no need for First[int](...)
Inference has limits — it cannot infer types purely from a function’s return type, and inference does not “flow backward” from context like variable assignment target types. When inference fails, you must specify type arguments explicitly:
var f float64 = 3.0
// n := Convert(f) // if Convert[T, U any](x T) U, U cannot be inferred!
n := Convert[float64, int](f) // must specify explicitly
Instantiation
“Instantiation” is the act of substituting concrete types for type parameters, producing a non-generic function or type:
type List[T any] []T
var IntList = List[int]{1, 2, 3} // instantiated type
var StringList = List[string]{"a", "b"} // different instantiated type
Function values can also be instantiated without being called immediately:
var maxInt = Max[int] // maxInt has type func(int, int) int
Each unique instantiation is treated as a distinct type/function by the type system, even though they share a single generic definition in source code.
The any Type
any is simply a predeclared alias for interface{}, introduced alongside generics for readability:
type any = interface{}
any is commonly used as a constraint meaning “no restriction — any type is allowed”:
func Identity[T any](v T) T {
return v
}
Note that any as a constraint is different from using interface{} as a regular (non-generic) parameter type — with generics, the compiler still enforces that a single concrete type is used consistently per instantiation, and you get that type back without needing a type assertion.
The comparable Constraint
comparable is a predeclared constraint satisfied by any type that supports == and !=. This includes:
- Basic types: numbers, strings, booleans
- Pointers
- Channels
- Arrays of comparable types
- Structs whose fields are all comparable
It excludes slices, maps, and functions (which are not comparable in Go).
func Contains[T comparable](s []T, target T) bool {
for _, v := range s {
if v == target {
return true
}
}
return false
}
Since Go 1.20, comparable also (somewhat controversially) permits types whose comparison could panic at runtime (like interfaces holding uncomparable dynamic values), so comparable is not a 100%-panic-safe guarantee in every corner case — but for typical use it works as expected for maps keys, ==, etc.
Union Elements and the ~ (Underlying Type) Operator
A union element in a constraint interface lists alternative types separated by |:
type Numeric interface {
int | int32 | int64 | float32 | float64
}
A type argument satisfies a union element if it is identical to (or, when combined with ~, has the same underlying type as) any one of the listed terms.
Restrictions on union elements:
- Terms in a union cannot overlap (you can’t have both
intand~intin the same union, since the latter already includes the former) - You cannot include interfaces with methods inside a union that also has more than one term in some circumstances
- A union element with a single term and no
~behaves like a normal type restriction
// Valid:
type Ordered interface {
~int | ~float64 | ~string
}
// Invalid — overlapping terms:
// type Bad interface {
// int | ~int
// }
Generic Data Structures
Generics shine when implementing reusable containers and algorithms. Common examples:
Linked List
type Node[T any] struct {
Value T
Next *Node[T]
}
type LinkedList[T any] struct {
Head *Node[T]
Tail *Node[T]
Len int
}
func (l *LinkedList[T]) Append(v T) {
n := &Node[T]{Value: v}
if l.Head == nil {
l.Head = n
l.Tail = n
} else {
l.Tail.Next = n
l.Tail = n
}
l.Len++
}
Binary Search Tree
type BSTNode[T cmp.Ordered] struct {
Value T
Left, Right *BSTNode[T]
}
func (n *BSTNode[T]) Insert(v T) *BSTNode[T] {
if n == nil {
return &BSTNode[T]{Value: v}
}
if v < n.Value {
n.Left = n.Left.Insert(v)
} else if v > n.Value {
n.Right = n.Right.Insert(v)
}
return n
}
Generic Set
type Set[T comparable] struct {
m map[T]struct{}
}
func NewSet[T comparable](items ...T) *Set[T] {
s := &Set[T]{m: make(map[T]struct{})}
for _, item := range items {
s.Add(item)
}
return s
}
func (s *Set[T]) Add(v T) { s.m[v] = struct{}{} }
func (s *Set[T]) Has(v T) bool { _, ok := s.m[v]; return ok }
func (s *Set[T]) Remove(v T) { delete(s.m, v) }
func (s *Set[T]) Len() int { return len(s.m) }
Standard Library Generic Packages
Go 1.21+ ships several generic packages:
slices
import "slices"
slices.Sort(s)
slices.Contains(s, x)
slices.Index(s, x)
slices.Reverse(s)
slices.Max(s)
slices.Min(s)
slices.Equal(s1, s2)
slices.Clone(s)
slices.Compact(s)
slices.Insert(s, i, v...)
slices.Delete(s, i, j)
slices.BinarySearch(s, x)
maps
import "maps"
maps.Keys(m) // returns an iterator (Go 1.23+) or a slice (older)
maps.Values(m)
maps.Equal(m1, m2)
maps.Clone(m)
maps.DeleteFunc(m, f)
maps.Copy(dst, src)
cmp
import "cmp"
cmp.Compare(a, b)
cmp.Less(a, b)
cmp.Or(a, b, c) // returns first non-zero value
iter (Go 1.23+)
Introduces the Seq[T] and Seq2[K, V] iterator function types used by range-over-func, enabling generic iterators:
func Seq[T any](s []T) iter.Seq[T] {
return func(yield func(T) bool) {
for _, v := range s {
if !yield(v) {
return
}
}
}
}
Variadic Generics and Multiple Type Parameters
Go does not support true “variadic type parameters” (an arbitrary number of type parameters), unlike some languages’ variadic templates. However, you can combine generics with variadic value parameters:
func NewSlice[T any](items ...T) []T {
return items
}
Multiple type parameters with independent constraints are supported and common:
func Zip[A, B any](as []A, bs []B) []struct {
First A
Second B
} {
n := min(len(as), len(bs))
result := make([]struct {
First A
Second B
}, n)
for i := 0; i < n; i++ {
result[i] = struct {
First A
Second B
}{as[i], bs[i]}
}
return result
}
Performance Considerations
Go’s generics implementation uses a hybrid strategy called GC shape stenciling combined with dictionary passing:
- Types with the same “GC shape” (same size, same pointer layout) can share a single compiled implementation, with a runtime “dictionary” passed in to handle type-specific operations (like calling the right comparison function).
- This keeps binary size more reasonable than full monomorphization (like Rust or C++ templates, which generate a separate copy of code per type) while still being faster than
interface{}-based dynamic dispatch in most cases.
Key performance takeaways:
- Generic code is generally faster than
interface{}-based equivalents because it avoids boxing (heap allocation for interface values holding non-pointer types) and avoids type assertions at runtime. - Generic code is usually slightly slower than fully monomorphized, hand-written type-specific code, due to the dictionary-passing overhead for shape-sharing cases — though the gap has narrowed significantly with each Go release as the compiler team keeps optimizing.
- Benchmarks (
testing.B) should always be used to verify actual performance in your specific case rather than assuming. - Since Go 1.18, there have been ongoing compiler improvements (e.g., better inlining of generic functions) across releases, so newer Go versions tend to have better generics performance than 1.18 itself.
Common Pitfalls
-
Trying to use
Tas a zero value withoutvar zero T:func First[T any](s []T) T { if len(s) == 0 { return nil // COMPILE ERROR if T could be a non-nilable type } return s[0] } // Correct: func First[T any](s []T) T { var zero T if len(s) == 0 { return zero } return s[0] } -
Forgetting that methods can’t add new type parameters (see the Generic Methods section above).
-
Assuming
comparablemeans “always safe to use==” — interface values with uncomparable dynamic types can still panic at runtime undercomparablesince Go 1.20’s relaxation. -
Overusing generics where a simple
interface{}/anyor even a concrete type would be clearer and sufficient — Go’s philosophy favors readability, and not every function needs to be generic. -
Forgetting
~in constraints, which silently excludes custom/defined types that share the same underlying type. -
Confusing type constraints with type assertions — constraints are compile-time only; they don’t let you dynamically dispatch behavior at runtime the way an interface method set does.
-
Trying to instantiate generic types with method values incorrectly — remember
Stack[int]{}.Pushneeds a pointer receiver context correctly set up.
Best Practices
- Don’t generic-ify prematurely. Start with concrete types; extract a generic version once you see real duplication across at least two or three types.
- Prefer standard library generic packages (
slices,maps,cmp) over hand-rolled equivalents. - Use meaningful but conventional type parameter names (
T,K,V,Efor simple cases; longer names for complex, multi-parameter generic code where clarity matters more). - Keep constraints as narrow as necessary, but no narrower — don’t require
comparableif you only needany. - Document behavior around zero values explicitly since
nilisn’t always valid forT. - Benchmark before assuming performance parity with hand-written specialized code.
- Avoid deeply nested generic types (like
Map[string, List[Pair[int, Set[string]]]]) as they hurt readability — consider named type aliases to simplify signatures.
Generics vs. interface{} / any vs. Code Generation
| Aspect | Generics | interface{}/any | Code Generation |
|---|---|---|---|
| Compile-time type safety | ✅ Full | ❌ None (runtime assertions needed) | ✅ Full |
| Performance | Good (dictionary passing) | Slower (boxing, dynamic dispatch) | Best (fully specialized) |
| Code duplication | None (single generic definition) | None, but loses type safety | Duplicated generated code |
| Build complexity | Native language feature | Native language feature | Requires extra build step/tooling |
| Readability | Good, if not overused | Simple but less safe | Can be verbose, generated files |
Real-World Examples
Generic Result/Option Type
type Result[T any] struct {
Value T
Err error
}
func Ok[T any](v T) Result[T] {
return Result[T]{Value: v}
}
func Err[T any](err error) Result[T] {
return Result[T]{Err: err}
}
func (r Result[T]) IsOk() bool {
return r.Err == nil
}
Generic Cache
type Cache[K comparable, V any] struct {
mu sync.RWMutex
items map[K]V
}
func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{items: make(map[K]V)}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.items[key]
return v, ok
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}
Generic Pipeline / Functional Composition
func Compose[A, B, C any](f func(A) B, g func(B) C) func(A) C {
return func(a A) C {
return g(f(a))
}
}
double := func(x int) int { return x * 2 }
toString := func(x int) string { return fmt.Sprintf("%d", x) }
doubleThenString := Compose(double, toString)
fmt.Println(doubleThenString(5)) // "10"
Conclusion
Go generics, introduced in Go 1.18 and steadily refined through Go 1.21–1.23+, bring type-safe, reusable abstractions to the language without sacrificing its core values of simplicity and performance. While the feature set is intentionally more conservative than in languages like Rust, C++, or Java/C# (no variadic type parameters, no new type parameters on methods, limited specialization), it covers the vast majority of practical use cases: generic containers, algorithms, and utility functions.
The key to using generics well in Go is restraint: use them where they genuinely eliminate duplication or unlock reusable, type-safe abstractions — and prefer the standard library’s slices, maps, and cmp packages whenever they already solve your problem.