The copy-paste trap
You write a Max function for integers. It compares two values and returns the larger one. Two days later you need the same logic for floats. You copy the function, rename it to MaxFloat, and change the type signature. A week later you need it for custom numeric types. Your codebase starts looking like a factory of near-identical functions. The algorithm is identical. Only the type changes. Go 1.18 solved this with generics. Instead of writing the same function three times, you write it once with a placeholder. The compiler fills in the actual type when you call it.
Generics as a compile-time mold
Generics are a template system that runs at compile time. Think of a generic function as a cookie cutter. The cutter defines the shape and the steps. You press it into dough made of flour, or dough made of sugar, or dough made of chocolate chips. The cutter does not care about the ingredients. It just guarantees that whatever comes out matches the shape. In Go, the ingredients are types. The compiler checks that the type you pass fits the constraints you defined, then generates specialized machine code for that exact type.
Before generics, Go developers used interface{} to achieve flexibility. That approach works like a universal shipping box. You throw anything inside, seal it, and pass it around. When you need the contents, you open the box and guess what is inside. If you guess wrong, the program crashes at runtime. Generics remove the guessing. The compiler knows the exact type at compile time. You get flexibility without sacrificing safety or performance. Go waited until version 1.18 to add generics because the language designers refused to compromise on simplicity or runtime speed. They built a system that generates concrete code instead of adding a virtual dispatch layer.
The minimal syntax
Here is the simplest generic function: a placeholder type parameter, a slice of that type, and a return of that type.
package main
import "fmt"
// First returns the first element of a slice.
// [T any] tells the compiler to accept any type for T.
func First[T any](slice []T) T {
// The compiler verifies slice is non-empty at runtime,
// but the type system guarantees the return matches the input type.
return slice[0]
}
func main() {
// T becomes int here. The compiler generates a specialized version.
fmt.Println(First([]int{10, 20, 30}))
// T becomes string here. A separate specialized version is generated.
fmt.Println(First([]string{"alpha", "beta"}))
}
The [T any] syntax sits between the function name and the opening parenthesis. T is just a convention. You can name it Element, ValueType, or T. The community sticks to T for single parameters and K, V for key-value pairs. The any keyword is a constraint. It means accept any type, including functions, channels, and interfaces. When you call First([]int{...}), the compiler substitutes T with int. It does not create a runtime abstraction. It generates a concrete First_int function in the compiled binary. When you call it with strings, it generates First_string. This process is called monomorphization. The runtime sees only concrete types. There is zero overhead compared to writing the functions by hand.
Type inference handles most calls automatically. You rarely need to write First[int](slice). The compiler looks at the argument, deduces the type, and fills in the parameter. Explicit type arguments are only required when the compiler cannot guess, such as when calling a generic function with no arguments or when multiple types could satisfy the constraints.
Generics are templates, not runtime features. The binary contains the expanded code.
Realistic usage with constraints
Real code rarely uses any because it is too permissive. You usually want to restrict the type to things that support specific operations. Go uses type constraints to define what a generic type can do. Here is a generic cache that only accepts types that can be compared with ==.
package main
import (
"fmt"
"time"
)
// comparable is a built-in constraint for types that support == and !=.
type Cache[K comparable, V any] struct {
// map requires comparable keys. The constraint guarantees this.
data map[K]V
// expiry tracks when each entry should be removed.
expiry map[K]time.Time
}
// NewCache creates a fresh cache instance.
func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{
data: make(map[K]V),
expiry: make(map[K]time.Time),
}
}
// Set adds a value with a 1-minute TTL.
func (c *Cache[K, V]) Set(key K, value V) {
c.data[key] = value
c.expiry[key] = time.Now().Add(time.Minute)
}
// Get returns the value and true if it exists and is not expired.
func (c *Cache[K, V]) Get(key K) (V, bool) {
// Zero value of V is returned if the key is missing.
val, exists := c.data[key]
if !exists {
return val, false
}
// Check expiration without blocking or complex logic.
if time.Now().After(c.expiry[key]) {
delete(c.data, key)
delete(c.expiry, key)
return val, false
}
return val, true
}
func main() {
// K becomes string, V becomes int.
cache := NewCache[string, int]()
cache.Set("counter", 42)
val, ok := cache.Get("counter")
fmt.Println(val, ok)
}
The comparable constraint is a built-in interface that covers all types supporting equality checks. It excludes slices, maps, and functions because Go does not allow == on those types. When you define a struct with type parameters, every method on that struct inherits the same parameters. The receiver (c *Cache[K, V]) carries the constraints forward. This keeps the type system consistent across the entire type.
Convention aside: Go's type system favors explicit constraints over implicit magic. The community rarely defines complex constraint hierarchies. If a constraint grows beyond three or four methods, developers usually switch to a standard interface. Generics in Go are a tool for reducing duplication, not a replacement for interface-based design.
Constraints are just interfaces under the hood. Treat them as contracts, not shortcuts.
Where generics break down
Generics introduce a new layer of compiler checks. The most common mistake is using a type that violates the constraint. If you try to use a slice as a map key in the cache above, the compiler rejects it with invalid operation: map index expression of type []int (slice of uncomparable type int). Slices cannot be compared with ==, so they fail the comparable constraint. The compiler catches this before the program runs.
Another trap is overusing any. When you write [T any], you lose the ability to call methods or use operators on T. If you try to add two T values inside the function, the compiler stops you with invalid operation: operator + not defined on t (variable of type T). Go does not support numeric constraints out of the box. You must define your own constraint interface that lists the allowed types, or stick to concrete types if the operation is highly specific.
Generic type parameters also cannot be used as type assertions. You cannot write t.(string) inside a generic function because T is not an interface. The compiler will complain with type assertion on non-interface type T. If you need runtime type inspection, you are back to any and reflection, which defeats the purpose of generics.
Method sets on generic types have strict rules. You cannot define a method directly on a generic type parameter. You can only define methods on generic structs or interfaces. If you try to attach a method to a bare type parameter, the compiler rejects it with invalid receiver type T (T is a type parameter). This restriction keeps the type system predictable and prevents ambiguous dispatch at compile time.
Generics enforce rules at compile time. Break the contract and the build fails.
When to reach for them
Use generics when you need the same algorithm to work across multiple types without runtime overhead. Use any when you are building a library that must accept truly arbitrary types and you will handle type inspection at runtime. Use concrete types when the function only makes sense for one specific type or a tightly coupled group. Use interfaces when you care about behavior rather than the exact underlying type. Use standard library collections like slices and maps when they already provide the generic utility you need.
Pick the simplest tool that satisfies the type requirements. Generics are powerful, but concrete code is easier to read.