Generics in Go
Use type parameters and constraints in Go 1.18+, write generic functions and types, and apply the comparable and any constraints.
Why Generics?
Before generics, Go developers faced an uncomfortable choice when writing type-flexible code: use interface{} and lose compile-time type safety, or copy the same function body for every concrete type. Neither is good. Generics give you a third option — write the function once with a type parameter, and the compiler generates the correct implementation for each type you use it with. You get the flexibility of interface{} with the safety of concrete types.
// Before generics — must write a separate function for each type
func ContainsInt(slice []int, val int) bool {
for _, v := range slice {
if v == val {
return true
}
}
return false
}
// The same logic repeated for strings, floats, and every other type...
// With generics — one function works for any comparable type
// [T comparable] is the type parameter list; comparable is the constraint
func Contains[T comparable](slice []T, val T) bool {
for _, v := range slice {
if v == val {
return true
}
}
return false
}
fmt.Println(Contains([]int{1, 2, 3}, 2)) // true — T inferred as int
fmt.Println(Contains([]string{"a", "b"}, "c")) // false — T inferred as string
Type Parameters
Type parameters are declared in square brackets after the function name, with an optional constraint. When you call a generic function, Go infers the type parameter from the arguments — you rarely need to specify it explicitly. Multiple type parameters are separated by commas, each with its own constraint.
// [T, U any] declares two independent type parameters, both unconstrained
// Map transforms a []T into a []U using a function from T to U
func Map[T, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v) // fn converts each T to a U
}
return result
}
nums := []int{1, 2, 3, 4, 5}
// T=int, U=int — double each number
doubled := Map(nums, func(n int) int { return n * 2 })
// [2 4 6 8 10]
// T=int, U=string — convert each number to its string representation
asStr := Map(nums, func(n int) string { return fmt.Sprintf("%d", n) })
// ["1" "2" "3" "4" "5"]
Built-in Constraints
any — no constraint
any (an alias for interface{}) places no restriction on the type parameter — any type works. Use it when the function body doesn’t need to perform any operation that requires type-specific support (no comparison, no arithmetic).
// First works for any slice type — it only indexes, which requires no constraint
func First[T any](slice []T) (T, bool) {
if len(slice) == 0 {
var zero T // zero value of T — 0, "", nil, etc. depending on the type
return zero, false
}
return slice[0], true
}
comparable — supports == and !=
The comparable constraint allows types that support equality comparison with == and !=. It’s required when you need to use a type as a map key or compare values directly. Slices, maps, and functions are not comparable and cannot satisfy this constraint.
// Index requires T to be comparable so we can use v == val
func Index[T comparable](slice []T, val T) int {
for i, v := range slice {
if v == val {
return i // return the first index where the value is found
}
}
return -1 // not found
}
constraints from golang.org/x/exp
For numeric constraints (ordered types that support <, >, +, etc.), the golang.org/x/exp/constraints package provides ready-made constraint interfaces. constraints.Ordered covers all integer types, floating-point types, and strings — anything you can sort.
import "golang.org/x/exp/constraints"
// Min works for any ordered type — integers, floats, and strings
func Min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
fmt.Println(Min(3, 5)) // 3
fmt.Println(Min(3.14, 2.71)) // 2.71
fmt.Println(Min("abc", "abd")) // abc
Defining Custom Constraints
A constraint is just an interface. Union types (int | string) list the exact set of allowed types. The tilde prefix (~int) extends the constraint to cover any type whose underlying type is int — this is important for user-defined types like type Celsius float64, which you’d want to work with numeric functions even though it’s technically a distinct type.
// Exact union — only these exact built-in types
type Integer interface {
int | int8 | int16 | int32 | int64
}
// Tilde union — covers defined types with these underlying types too
// type MyInt int would satisfy ~int but not int
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
func Sum[T Signed](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
// FloatTemp covers Celsius and Fahrenheit because their underlying type is float64
type Celsius float64
type Fahrenheit float64
type FloatTemp interface {
~float64 | ~float32
}
func Average[T FloatTemp](nums []T) T {
var sum T
for _, n := range nums {
sum += n
}
return sum / T(len(nums))
}
temps := []Celsius{20, 22, 25, 18}
fmt.Println(Average(temps)) // 21.25
Generic Types
Generics aren’t limited to functions — structs, interfaces, and type aliases can all have type parameters. A generic type is instantiated by specifying the type argument in square brackets. This is how you build type-safe collection types (stacks, queues, trees) without code duplication or losing type information.
// Stack[T] is a LIFO collection that works for any element type
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
// Pop returns the top element and whether the stack was non-empty
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false // return zero value when empty
}
n := len(s.items)
v := s.items[n-1]
s.items = s.items[:n-1]
return v, true
}
func (s *Stack[T]) Len() int { return len(s.items) }
// Type argument [int] is required when declaring the variable — can't be inferred here
intStack := &Stack[int]{}
intStack.Push(1)
intStack.Push(2)
v, _ := intStack.Pop()
fmt.Println(v) // 2 — LIFO order
strStack := &Stack[string]{}
strStack.Push("hello")
Generic Map and Filter
Functional collection utilities are the canonical use case for generics. Before Go 1.18, every project either copied these helpers for each type or used reflect, which is slow and untyped. With generics, a single Filter and Reduce implementation is fast (no reflection), type-safe, and reusable across any type.
// Filter returns a new slice containing only the elements for which pred returns true
func Filter[T any](slice []T, pred func(T) bool) []T {
var result []T
for _, v := range slice {
if pred(v) {
result = append(result, v)
}
}
return result
}
// Reduce folds a slice into a single value using an accumulator function
func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U {
result := initial
for _, v := range slice {
result = fn(result, v)
}
return result
}
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
// [2 4 6 8 10]
sum := Reduce(nums, 0, func(acc, n int) int { return acc + n })
// 55
doubled := Map(nums, func(n int) int { return n * 2 })
// [2 4 6 8 10 12 14 16 18 20]
Generic Set
A set is a collection of unique values with O(1) membership tests. Before generics, you’d implement this as map[string]struct{} and repeat the implementation for every element type. The generic Set[T] works for any comparable type and exposes a clean API that hides the map internals.
// Set[T] is an unordered collection of unique values of type T
// T must be comparable so values can be used as map keys
type Set[T comparable] struct {
items map[T]struct{} // struct{} uses zero bytes — we only care about the keys
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{items: make(map[T]struct{})}
}
func (s *Set[T]) Add(v T) {
s.items[v] = struct{}{} // adding an existing key is a no-op — guarantees uniqueness
}
func (s *Set[T]) Contains(v T) bool {
_, ok := s.items[v]
return ok
}
func (s *Set[T]) Remove(v T) {
delete(s.items, v)
}
func (s *Set[T]) Len() int { return len(s.items) }
// Union returns a new set containing all elements from both sets
func (s *Set[T]) Union(other *Set[T]) *Set[T] {
result := NewSet[T]()
for k := range s.items {
result.Add(k)
}
for k := range other.items {
result.Add(k)
}
return result
}
s1 := NewSet[int]()
s1.Add(1); s1.Add(2); s1.Add(3)
s2 := NewSet[int]()
s2.Add(3); s2.Add(4); s2.Add(5)
union := s1.Union(s2)
fmt.Println(union.Contains(4)) // true
fmt.Println(union.Len()) // 5 — duplicate 3 counted once
Type Inference
Go infers type parameters from the function arguments, so you almost never need to write the type parameter explicitly at the call site. Explicit type parameters are only required when there is no argument from which the compiler can infer the type — for example, a function that takes no arguments of the parameterised type.
// Explicit type argument — rarely needed
Contains[int]([]int{1, 2, 3}, 2)
// Inferred — preferred style; the compiler figures out T from the arguments
Contains([]int{1, 2, 3}, 2)
// Must be explicit — no argument lets the compiler infer T
func Zero[T any]() T {
var v T
return v
}
n := Zero[int]() // must specify — compiler can't infer T with no arguments
s := Zero[string]()
Practical Example — Generic Result Type
This example ports the Rust/Haskell Result type to Go using generics. A Result[T] wraps either a successful value or an error, making the presence of a potential failure explicit in the type signature. It’s useful for functional-style pipelines where you want to thread success and failure through a chain of operations without nested if err != nil blocks.
package main
import "fmt"
// Result[T] holds either a value of type T or an error — never both
type Result[T any] struct {
value T
err error
}
// Ok creates a successful Result wrapping the given value
func Ok[T any](v T) Result[T] { return Result[T]{value: v} }
// Err creates a failed Result wrapping the given error
func Err[T any](err error) Result[T] { return Result[T]{err: err} }
func (r Result[T]) Unwrap() (T, error) { return r.value, r.err }
func (r Result[T]) IsOk() bool { return r.err == nil }
// OrElse returns the value if successful, or the fallback if not
func (r Result[T]) OrElse(fallback T) T {
if r.err != nil {
return fallback
}
return r.value
}
func divide(a, b float64) Result[float64] {
if b == 0 {
return Err[float64](fmt.Errorf("division by zero"))
}
return Ok(a / b)
}
func main() {
r1 := divide(10, 3)
fmt.Printf("%.4f\n", r1.OrElse(0)) // 3.3333
r2 := divide(5, 0)
fmt.Println(r2.IsOk()) // false
_, err := r2.Unwrap()
fmt.Println(err) // division by zero
}