Notes on Using Go Generics

Life Without Generics

Suppose you need to write a function that sums values: one version for int, and another for float64:

func SumInts(nums []int) int {
var total int
for _, n := range nums {
total += n
}
return total
}
func SumFloats(nums []float64) float64 {
var total float64
for _, n := range nums {
total += n
}
return total
}

The two functions are identical except for their types. To avoid duplication, the usual option was any:

// Use any (that is, interface{}) as input. The cost is that you need a type assertion every time, and errors only blow up at runtime.
func SumAny(nums []any) any {
var total int
for _, n := range nums {
total += n.(int) // Passing in a float64 will panic
}
return total
}

Your First Generic Function

Generics are like parameters for types, written in square brackets after the function name:

func Sum[T int | float64](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
  • [T int | float64]: declares a type parameter T; the part after it is the constraint, meaning T can only be int or float64.
  • nums []T, return value T: inside the function signature, you can use T like any ordinary type.
  • var total T: the zero value of T. For int, it is 0; for float64, it is 0.0.

When calling the function, you usually do not need to write anything extra. The compiler infers T from the arguments:

Sum([]int{1, 2, 3}) // T is inferred as int, result is 6
Sum([]float64{1.5, 2.5}) // T is inferred as float64, result is 4
Sum[int]([]int{1, 2, 3}) // You can also specify it manually
Sum([]string{"a", "b"}) // Compilation error: string is not in the constraint

The error happens at compile time, not runtime. This is the biggest difference between generics and using any with type assertions.

How to Write Constraints

A constraint is essentially an interface, except it describes not only methods, but also “which types can be substituted.”

any: Any Type Is Allowed

When any is used as a constraint, it means there is no type restriction. But because of that, you cannot make any assumptions about the value; you can only move it around, compare pointers, or put it into containers. A classic example is Map:

func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, 0, len(s))
for _, v := range s {
result = append(result, f(v))
}
return result
}
names := Map([]int{1, 2, 3}, func(n int) string {
return fmt.Sprintf("no.%d", n)
})
// [no.1 no.2 no.3]

A function can have multiple type parameters. Here, T is the input element type, U is the output element type, and the two are connected by the signature of f.

comparable: Can Be Compared with ==

If you want to write == or != inside a function, the constraint must be comparable:

func Contains[T comparable](s []T, target T) bool {
for _, v := range s {
if v == target {
return true
}
}
return false
}
Contains([]string{"a", "b"}, "b") // true

comparable covers numbers, strings, booleans, pointers, channels, and structs and arrays whose fields are all comparable. Slices, maps, and funcs are not included, because they cannot be compared with == in the first place.

cmp.Ordered: Can Be Compared with < >

comparable only guarantees ==. To compare ordering, use cmp.Ordered from the standard library’s cmp package:

import "cmp"
func Max[T cmp.Ordered](s []T) (T, bool) {
var zero T
if len(s) == 0 {
return zero, false
}
m := s[0]
for _, v := range s[1:] {
if v > m {
m = v
}
}
return m, true
}
Max([]int{3, 1, 4}) // 4, true
Max([]string{"b", "a", "c"}) // "c", true

Custom Constraints and the ~ Symbol

When constraints get longer, extract them into a named interface:

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
}

The tilde in ~int is read as “all types whose underlying type is int.” The difference is here:

type Celsius float64
Sum([]Celsius{36.5, 37.2}) // This only passes if the constraint is ~float64; float64 alone will reject it

Without ~, only float64 itself counts; custom types like Celsius are excluded. In practice, when writing constraints, adding ~ by default is usually the right choice.

Constraints can also include methods, in which case they are no different from ordinary interfaces:

type Stringer interface {
String() string
}
func JoinAll[T Stringer](items []T) string {
parts := make([]string, 0, len(items))
for _, item := range items {
parts = append(parts, item.String())
}
return strings.Join(parts, ", ")
}

Generic Types

In addition to functions, structs can also have type parameters. For example, here is a type-safe Stack:

type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{}
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
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)
}
// Starting with Go 1.27, methods can also declare their own type parameters.
func (s *Stack[T]) MapTo[U any](f func(T) U) *Stack[U] {
result := NewStack[U]()
for _, item := range s.items {
result.Push(f(item))
}
return result
}

Using it looks like this:

s := NewStack[string]()
s.Push("a")
s.Push("b")
v, ok := s.Pop() // "b", true
s.Push(42) // Compilation error: the type has already been fixed as string
lengths := s.MapTo(func(v string) int { return len(v) })
lengths.Push(42) // OK, lengths is *Stack[int]
  • When creating a type instance, you cannot omit the type parameter. &Stack{} is invalid; you must write &Stack[string]{}. This is why it is common to pair the type with a NewStack[T]() constructor so inference can take effect.
  • The method receiver must include [T]. Starting with Go 1.27, methods themselves can also declare new type parameters, such as MapTo[U any] in the example above; in older versions of Go, this kind of requirement could only be written as a standalone function.
  • Interface methods still cannot declare type parameters, and a generic method cannot be used to implement an interface method.

The Standard Library Has Already Written This for You

The Contains and Max demonstrated above do not actually need to be implemented manually. The generic packages slices and maps already cover most everyday needs:

import (
"maps"
"slices"
)
nums := []int{3, 1, 4, 1, 5}
slices.Contains(nums, 4) // true
slices.Index(nums, 4) // 2
slices.Max(nums) // 5
slices.Sort(nums) // Sorts in place to [1 1 3 4 5]
slices.Reverse(nums)
people := []Person{{Name: "b"}, {Name: "a"}}
slices.SortFunc(people, func(x, y Person) int {
return cmp.Compare(x.Name, y.Name)
})
m := map[string]int{"a": 1, "b": 2}
keys := slices.Collect(maps.Keys(m)) // maps.Keys returns an iterator
slices.Sort(keys) // Map iteration order is random; sort it yourself if needed

When Not to Use Generics

Generics solve the problem of “applying the same logic to multiple types,” not “creating unnecessary abstractions”:

  • If only one type will use it, do not make it generic. Wait until a second type really appears before refactoring. The Go convention has always been to duplicate first and abstract later.
  • If you only need to call methods, use a regular interface. For something like func Print(s fmt.Stringer), writing it as func Print[T fmt.Stringer](s T) gives you no benefit; it is just more typing. The difference is that generics preserve the concrete type (you can return T or put it into []T), while an ordinary interface erases it.
  • If the logic varies by type, do not force it into generics. If type-based branching starts appearing inside the function, that means these are really two separate functions.

Summary

ConceptSyntaxPurpose
Type parameterfunc F[T any](...)Turns a type into a parameter
Type inferenceF(v) instead of F[int](v)Avoids manual specification in most cases
any[T any]Allows any type, but you cannot make assumptions about the value
comparable[T comparable]Allows ==, !=
cmp.Ordered[T cmp.Ordered]Allows <, >
~~int | ~float64Covers custom types with the same underlying type
Generic typetype Stack[T any] structType-safe containers
Type parameters go in square brackets; constraints determine what you can do with a value

Further Reading