When copy-pasting types becomes a maintenance trap
You wrote a Max function for []int. It works. Then you need one for []string. You copy the code, change int to string, and move on. A week later, you need Max for []float64. You copy again. Now you have three functions that do the exact same logic with different types. The code smells. You want to write the logic once and let the compiler handle the types.
Go 1.18 introduced generics to solve this. Generics let you define functions and types with placeholders for types, called type parameters. You specify rules for those placeholders using constraints. The compiler then generates specialized versions of your code for every type you actually use. You get the flexibility of dynamic typing with the safety and speed of static typing.
Generics are compile-time templates
Think of a generic function like a cookie cutter. The cutter defines the shape and the logic, but you can press it into dough of different flavors. The cutter doesn't care if the dough is chocolate chip or oatmeal; it just cuts the shape. In Go, the "dough" is the concrete type, and the "cutter" is the generic function.
The constraint is the rule about what kind of dough works. You can't press a cookie cutter into a liquid; similarly, you can't use a generic function on a type that doesn't meet the constraint. If you try to use a generic function with an incompatible type, the compiler rejects the code before it runs. There is no runtime overhead. The compiler substitutes the type parameter with the concrete type and generates code as if you had written it by hand. This process is called monomorphization.
Type parameters live in square brackets after the function or type name. The constraint follows the parameter name. The syntax looks like [T constraint]. The name T is conventional; you can use any identifier, but single letters like T, K, V, or E are standard in the Go community.
Minimal example: a generic maximum function
Here's the simplest generic function: a Max that works on any slice where elements can be compared.
package main
import "fmt"
// Max returns the largest value in a slice of comparable types.
func Max[T comparable](items []T) T {
// Guard against empty slices to avoid index out of range panic.
if len(items) == 0 {
panic("Max: empty slice")
}
// Start with the first element as the baseline.
max := items[0]
// Compare every subsequent element against the current max.
for _, item := range items[1:] {
if item > max {
max = item
}
}
return max
}
func main() {
// Call with ints; compiler infers T as int.
fmt.Println(Max([]int{1, 5, 3}))
// Call with strings; compiler infers T as string.
fmt.Println(Max([]string{"apple", "banana", "cherry"}))
}
The constraint comparable is a built-in interface in Go. It matches any type that supports the == and != operators. This includes integers, floats, strings, pointers, and structs where all fields are comparable. Slices, maps, and functions are not comparable, so Max([][]int{...}) will fail to compile.
The compiler infers the type parameter from the arguments. You call Max([]int{...}), and the compiler sees the argument is []int. It deduces that T must be int. You rarely need to write the type explicitly. When you do, the syntax is Max[int](items). Explicit type arguments are useful when the compiler can't infer the type, such as when passing nil or when the type depends on a complex expression.
Generics are compile-time templates. No runtime magic.
How the compiler processes generics
When the compiler encounters a generic function, it doesn't generate code immediately. It stores the definition. When it sees a call site, it performs substitution. For Max([]int{...}), it creates a version of Max where every T becomes int. It checks that int satisfies comparable. It generates the machine code for that version.
If you call Max with []string elsewhere, the compiler generates a second version with T replaced by string. Each unique type combination gets its own copy of the code. This means generics have zero runtime cost compared to hand-written typed functions. The trade-off is binary size. If you use a large generic function with many types, the binary grows because the code is duplicated. In practice, this is rarely a problem. The compiler shares read-only data and only duplicates executable instructions.
Type inference works for function arguments but has limits. The compiler infers types based on the values you pass. If you write var result = Max(nil), the compiler doesn't know what T should be. You must write var result = Max[int](nil). Inference also doesn't work for return types in isolation. If a function returns T, the compiler can't infer T from the return value alone; it needs the call site to provide the type.
Convention aside: Go functions get a doc comment starting with the function name. // Max returns... is the standard. Tools like godoc and IDEs rely on this format. Always write the doc comment before the function signature.
Realistic example: a generic cache struct
Generics shine on types, not just functions. A container that holds values of different types while preserving type safety is a classic use case. Here's a thread-safe cache using type parameters on a struct.
package main
import (
"fmt"
"sync"
)
// Cache holds key-value pairs with thread-safe access.
type Cache[K comparable, V any] struct {
// mu protects the data map from concurrent access.
mu sync.Mutex
// data stores the cached values.
data map[K]V
}
// Get retrieves a value by key. Returns false if the key is missing.
func (c *Cache[K, V]) Get(key K) (V, bool) {
// Lock before reading to prevent data races.
c.mu.Lock()
defer c.mu.Unlock()
val, ok := c.data[key]
return val, ok
}
// Set stores a value for the given key.
func (c *Cache[K, V]) Set(key K, val V) {
// Lock before writing to prevent data races.
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = val
}
func main() {
// Create a cache mapping strings to integers.
var intCache Cache[string, int]
intCache.Set("count", 42)
val, ok := intCache.Get("count")
fmt.Println(val, ok) // 42 true
// Create a cache mapping strings to strings.
var strCache Cache[string, string]
strCache.Set("name", "Go")
val2, _ := strCache.Get("name")
fmt.Println(val2) // Go
}
The struct Cache[K comparable, V any] has two type parameters. K is the key type and must be comparable because map keys require equality checks. V is the value type and uses any, which is an alias for interface{}. any means "any type at all". You can store integers, strings, structs, or slices as values.
The methods Get and Set use the receiver (c *Cache[K, V]). The receiver includes the type parameters so the methods work with the specific instantiation of the cache. When you create intCache, the type is Cache[string, int]. The methods become Get(string) (int, bool) and Set(string, int). Type safety is preserved. You can't accidentally pass an integer key to a string-keyed cache.
Convention aside: Receiver names should be short and match the type. (c *Cache[K, V]) uses c for Cache. Never use this or self. The community expects one or two letters. Also, defer c.mu.Unlock() is the standard pattern for mutexes. It ensures the lock is released even if the function panics.
Type parameters on structs unlock flexible containers.
Pitfalls and compiler errors
Generics introduce new failure modes. Understanding these saves debugging time.
If you try to use a type parameter without declaring it, the compiler rejects the code with undefined: T. Type parameters must be declared in the signature. If you declare a type parameter but never use it, you get type parameter T is unused. Go requires all type parameters to be used in the function or type definition.
The any constraint is not comparable. A common mistake is using any for map keys. If you write type BadMap[K any] struct { data map[K]int }, the compiler complains with invalid operation: cannot use k as map index (type parameter K must be comparable to use as map index). Map keys must support equality. Always use comparable for keys.
Type aliases and constraints can be tricky. If you define type MyInt int, MyInt does not satisfy a constraint of int. The constraint int matches only the exact type int, not aliases. To include aliases, use the ~ operator. type Number interface { ~int } matches int, MyInt, and any other type whose underlying type is int. If you forget ~, the compiler says type MyInt does not satisfy constraint (MyInt is not int).
Zero values behave normally with generics. var t T creates a variable of type T initialized to the zero value. For integers, it's 0. For strings, it's "". For slices and maps, it's nil. This is useful for returning empty results. If a function returns T and you have no value, return var t T. The caller gets the correct zero value for their type.
Constraints are contracts. Violate them and the compiler stops you.
Decision: when to use generics
Generics reduce duplication, but they add complexity. Use them judiciously.
Use a generic function when you have identical logic across multiple types and want to avoid copy-pasting code.
Use a generic type when you need a container or wrapper that holds values of different types while preserving type safety.
Use the comparable constraint when your logic requires equality checks or map keys, such as deduplication or caching.
Use a custom interface constraint when you need specific methods, like Len() or Write(), to implement algorithms like sorting or serialization.
Use any when the type is truly opaque and you only store or pass it without inspecting its structure.
Use interface{} with type assertions when you need runtime type checking or polymorphic behavior that generics cannot express.
Use plain typed functions when the logic is specific to one type; generics add complexity that isn't always worth it.
Generics are a tool, not a goal. Write the simplest code that works. If you find yourself copy-pasting, reach for generics. If you're forcing a generic solution where a simple function suffices, step back.