The copy-paste trap
You write a stack for integers. It works. Two days later, you need a stack for strings. You copy the file, rename IntStack to StringStack, change int to string, and commit. A week later, you need a stack for User objects. You're copy-pasting the same logic with different type names. The code is identical except for the type.
Before Go 1.18, the alternative was interface{}. You could write one stack that accepted anything, but you lost type safety. Every retrieval required a type assertion. A wrong assertion caused a panic at runtime. Generics let you write the logic once and plug in the type later. You get the flexibility of interface{} with the safety of static types. The compiler checks everything. No runtime panics from wrong types.
Generics are placeholders
Think of a generic type parameter like a hole in a blueprint. You draw the blueprint with a hole where the material goes. When you build the structure, you fill the hole with concrete, steel, or wood. The blueprint stays the same. The material changes.
In Go, you declare a type parameter using square brackets. type Stack[T any] struct says Stack has a parameter T. any means T can be any type. When you instantiate Stack[int], the compiler replaces every T with int. The resulting code is identical to what you would have written by hand. The compiler generates a distinct type for every distinct type argument you use. This process is called monomorphization.
Generics are not magic. They are a compile-time template mechanism. The generated code runs at the same speed as handwritten code. There is no reflection overhead. There are no interface dispatch costs. The binary might grow slightly if you use many different types, but the performance is zero-cost.
A generic stack
Here's a generic stack. The [T any] syntax declares T as a type parameter. any is an alias for interface{} in the context of constraints. It means no restrictions.
package main
// Stack holds a sequence of items of type T.
// T is a type parameter that gets replaced by a concrete type when Stack is instantiated.
type Stack[T any] struct {
items []T // Slice stores the elements. T can be any type.
}
// Push adds an item to the top of the stack.
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item) // Append grows the slice if capacity is exceeded.
}
// Pop removes and returns the top item.
// It returns false if the stack is empty.
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T // Zero value of T depends on the concrete type (0 for int, "" for string).
return zero, false
}
idx := len(s.items) - 1
item := s.items[idx]
s.items = s.items[:idx] // Shrink the slice to remove the last element.
return item, true
}
The Pop method returns (T, bool). When the stack is empty, it returns the zero value of T. You don't need to know what T is. var zero T declares a variable of type T and initializes it to the zero value. If T is int, zero is 0. If T is string, zero is "". If T is a pointer, zero is nil. The compiler handles this automatically.
Convention aside: receiver names should be short and match the type. (s *Stack[T]) uses s. This is standard Go style. Don't use (this *Stack[T]) or (self *Stack[T]). Keep it to one or two letters.
How the compiler handles it
When you write s := &Stack[int]{}, the compiler generates a version of Stack specifically for int. It replaces every T with int. The resulting struct has a field items []int. The methods take int arguments. If you also use Stack[string], the compiler generates a second version with items []string.
These are distinct types at runtime. Stack[int] and Stack[string] are not compatible. You can't assign one to the other. This is different from type erasure in some other languages. Go preserves the concrete types. This allows the compiler to optimize based on the specific type. It also means the type system remains strict.
Generics are templates, not magic. The compiler expands them into concrete code.
Constraints and the comparable rule
Sometimes you need to restrict the types that can be used. Not every type supports every operation. If you want to use a generic type as a map key, the type must support equality checks. Go requires the comparable constraint for this.
Here's a utility function that transforms map values. It uses three type parameters. K comparable ensures the key type supports equality checks. V and R are unconstrained.
// TransformMap applies a function to every value in a map.
// K must be comparable to serve as a map key.
// V and R are unconstrained.
func TransformMap[K comparable, V any, R any](m map[K]V, fn func(V) R) map[K]R {
result := make(map[K]R, len(m)) // Pre-allocate capacity to avoid resizing.
for k, v := range m {
result[k] = fn(v) // Apply the function to each value.
}
return result
}
If you try to use a generic type as a map key without comparable, the compiler rejects it. You'll see invalid operation: operator == not defined on K. The compiler needs to know the type supports equality. Slices, maps, and functions are not comparable. You can't use them as map keys. If you write TransformMap[[]int, int, string], the compiler complains because []int is not comparable.
Constraints define the rules. The compiler enforces them.
Custom constraints
You can define custom constraints using interfaces. This lets you restrict types to a specific set or require specific methods. The ~ operator allows underlying types. ~int matches int and any named type based on int.
Here's a custom constraint for numeric types. It allows Sum to accept slices of integers or floats.
// Number is a type constraint for numeric types.
// It allows the generic function to accept int, float64, etc.
type Number interface {
~int | ~float64 // Tilde means the type is based on int or float64.
}
// Sum adds a slice of numbers.
// T must satisfy the Number constraint.
func Sum[T Number](nums []T) T {
var total T // Zero value for T is 0 for both int and float64.
for _, n := range nums {
total += n // Addition works because T is numeric.
}
return total
}
Sum[int] works. Sum[float64] works. Sum[MyInt] works if MyInt is defined as type MyInt int. The ~ operator makes the constraint flexible. Without ~, Sum[MyInt] would fail because MyInt is not exactly int.
Convention aside: use ~ carefully. It can make constraints too broad. If you only want int, don't use ~int. Use int. The tilde is powerful but can hide bugs if you accept types you didn't intend.
Pitfalls and compiler errors
Go's type inference is conservative. It often requires explicit type arguments. If you call Sum([]int{1, 2, 3}), the compiler can infer T is int. But in complex expressions, inference might fail. You'll see cannot infer T. Add the type parameter explicitly: Sum[int](nums). Don't fight inference. Explicit is better than implicit when the compiler complains.
Generic methods on non-generic types are not allowed. You can't write func (s *Stack) Push[T any](item T). The type parameters must be on the type definition. If you need a generic method, the receiver type must also be generic. This design keeps the type system predictable. If you hit this wall, move the generic logic to a standalone function or make the type generic.
Type parameters cannot be used as type arguments for other types in all contexts. You can't write type Wrapper[T any] struct { inner *T }. Pointers to type parameters are not allowed. Use *T only if T is a pointer type. This limitation prevents complex pointer arithmetic and keeps memory layout simple.
The worst generic bug is the one that compiles but does the wrong thing. Generics catch type errors at compile time, but they don't catch logic errors. Test your generic code with multiple types. Edge cases might behave differently for different types.
When to use generics
Generics are a tool. They solve specific problems. Don't use them everywhere.
Use generics when the logic is identical across types and you want compile-time type safety. Collections, algorithms, and utility functions are good candidates.
Use interface{} with type assertions when you need to handle a dynamic set of types that aren't known at compile time. Parsing JSON into a flexible structure or building a plugin system often requires interface{}.
Use concrete types when the operation is specific to one type or a small set of types. The overhead of generics isn't worth it for a single use case. Write the struct for that type.
Use a custom constraint when you need to enforce specific methods or type unions. If you're writing a math library, constrain types to numeric interfaces. If you're writing a cache, constrain keys to comparable.
Don't over-engineer. If you only need one type, write the struct for that type. Generics add complexity. Keep it simple until you need the flexibility.
Generics are templates. Fill them in at the call site.