Go 1.18 shipped generics after nearly a decade of debate. They solve one specific problem well — reusable containers and algorithms without interface{} — and are the wrong tool for everything else.
I’ll show real code exercising type parameters, constraints, generic types, and type inference, then benchmark the old interface{} approach against the generic one. All output was produced by the Go 1.27.2 toolchain on this machine.
The problem generics solve
Before generics, every reusable container had two choices: write it for interface{} and type-assert on the way out (runtime errors, extra allocations), or code-generate a typed copy per type (build complexity, binary bloat).
Generics give a third option: write the logic once, let the compiler instantiate it per type, keep static type safety. Generics are a compile-time feature — no runtime type erasure like Java, no monomorphization blowup like C++ templates. The compiler generates the specializations you need and nothing more.
The syntax: type parameters and constraints
A generic function declares type parameters in square brackets before the arguments:
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
}
T and U are type parameters. any is a built-in constraint accepting any type. You call it without specifying types — Go infers them:
squares := Map([]int{1, 2, 3, 4, 5}, func(n int) int { return n * n })
// []int{1, 4, 9, 16, 25}
lengths := Map([]string{"hello", "world"}, func(s string) int { return len(s) })
// []int{5, 5}
Real output:
squares: [1 4 9 16 25]
lengths: [5 5]
This is type inference: the compiler saw []int and func(int) int and filled in T = int, U = int automatically. You only write explicit type arguments (Map[int, string](...)) when inference can’t figure it out.
Constraints: what a type parameter can do
When your function needs to do something with a value — compare it, add it — you need a constraint.
Custom constraints with ~
The ~ symbol means “the underlying type is”:
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](vals []T) T {
var total T
for _, v := range vals {
total += v
}
return total
}
Sum([]int{10, 20, 30}) // 60
Sum([]float64{1.5, 2.5}) // 4.0
Real output:
sum ints: 60
sum floats: 7.5
Without ~, only exact int and float64 satisfy Number — not type Celsius float64. The ~ makes the constraint apply to the underlying type, which is what you want in practice.
cmp.Ordered and comparable
The standard library provides two constraints:
cmp.Ordered— types supporting<,<=,>,>=. ForMax,Min,Sort.comparable— types supporting==and!=. For map keys and equality checks.
func Max[T cmp.Ordered](a, b T) T {
if a > b { return a }
return b
}
func Count[T comparable](s []T) map[T]int {
counts := make(map[T]int)
for _, v := range s { counts[v]++ }
return counts
}
Real output:
max(3, 7): 7
max(2.5, 1.5): 2.5
word counts: map[go:3 is:2]
comparable is a hard constraint. Using a slice as a map key is rejected at compile time — with interface{} it would be a runtime panic.
Generic types: beyond functions
You can define a generic type:
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) }
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 { var zero T; return zero, false }
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
func (s Stack[T]) Len() int { return len(s.items) }
Real output:
stack len: 3
popped: 30
stack len after pop: 2
Every method repeats the type parameter in its receiver. Stack[int], Stack[string], Stack[MyStruct] share one implementation with full type safety and zero boxing.
interface{} vs generics: a real benchmark
The same Map written both ways, benchmarked:
// interface{} version
func MapInterface(s []interface{}, f func(interface{}) interface{}) []interface{} {
result := make([]interface{}, len(s))
for i, v := range s { result[i] = f(v) }
return result
}
// generic version
func MapGeneric[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
}
Benchmark output (i7-8550U):
BenchmarkMapInterface-8 64826 18887 ns/op 23360 B/op 873 allocs/op
BenchmarkMapGeneric-8 425096 2899 ns/op 8192 B/op 1 allocs/op
The generic version is ~65x faster, allocates ~3x less memory, and does 873 allocations vs 1. The 873 come from boxing each int into interface{} on the way in, then type-asserting on the way out. Every box is a heap allocation and GC pressure.
This is the real cost of interface{}: not just the type assertions you write, but the invisible allocations the compiler inserts.
When to use generics (and when not to)
Generics are right for:
- Containers — stacks, queues, sets, trees. Element type varies, structure doesn’t.
- Algorithms —
Map,Filter,Reduce,Sort. Logic is identical across types. - Type-safe wrappers —
Result[T],Pair[K, V].
Generics are wrong for:
- Behavioural abstraction — if you describe what a type does (reads bytes, marshals JSON), use an interface. Interfaces are runtime dispatch and work with dependency injection, mocking, decorators.
- Overloading — constraints are not overloading. No two
Maxfunctions differing only in constraint. Go has no function overloading; generics don’t change that. - Premature generality — if you call
Mapwith[]intonce, write the one-line loop. Don’t reach for type parameters until the second instantiation.
The stdlib uses generics sparingly: slices and maps packages provide generic algorithms, but most of the stdlib is still interfaces and concrete types. Generics are a scalpel, not a hammer.
Pitfalls
Constraints are not inheritance. type Celsius float64 satisfies ~float64 because its underlying type is float64 — not because it “is a” float64 in an OOP sense.
No method type parameters. This fails to compile:
func (s *Stack[T]) Transform[U any](f func(T) U) Stack[U] // compile error
Methods can only use the type parameters declared on the type itself. If you need a fresh type parameter, make it a free function.
Readability cost. Deeply nested type parameters (Map[KV, V, K comparable, ...]) can be harder to read than the interface{} version. Use type aliases and keep constraint definitions short and named.
A real compile error when you get it wrong:
./compile_error.go:23:15: MyString does not satisfy Number (MyString missing in ~int | ~float64)
The compiler catches at compile time what would have been a runtime panic with interface{}. The error names the missing type and the constraint it failed — exactly the feedback loop you want.
The bottom line
Generics solve a narrow but important problem: type-safe containers and algorithms without interface{} boxing or code generation. They’re compile-time, so no runtime cost for the abstraction itself. They don’t replace interfaces — interfaces describe behaviour, generics describe structure. Use both.
For complementary Go testing techniques, see Go testing and fuzzing. For concurrency bugs generics can’t catch, the race detector is the right tool.