When copy-pasting stops making sense
You write a Max function for integers. It works. Two weeks later you need the same logic for strings. You copy the function, rename it to MaxString, change the parameter types, and move on. A month later you need it for floats. You copy again. Your codebase starts looking like a factory of nearly identical functions that differ only in their type signatures.
Go 1.18 solved this with generic functions. Instead of writing the same logic three times, you write it once with a type parameter. The compiler fills in the blanks for you. You keep the safety of static typing without the duplication tax.
The mold analogy
Think of a generic function like a metal casting mold. You carve the shape once. When you pour aluminum, you get an aluminum part. When you pour steel, you get a steel part. The mold doesn't care what metal you use, as long as the metal fits the cavity.
In Go, the type parameter is the cavity. The constraint is the rule that says which metals are allowed. The compiler generates a separate, type-specific version of the function for every type you actually use. There is no runtime penalty. No reflection. No type assertions. The generated code runs exactly as fast as a hand-written concrete function.
A minimal example
Here is the standard Max function written with a type parameter.
package main
import (
"cmp"
"fmt"
)
// Max returns the larger of two comparable values.
func Max[T cmp.Ordered](a, b T) T {
// The constraint guarantees T supports comparison operators.
if a < b {
return b
}
return a
}
func main() {
// The compiler infers T as int from the arguments.
fmt.Println(Max(1, 2))
// The compiler infers T as string from the arguments.
fmt.Println(Max("alpha", "beta"))
}
The [T cmp.Ordered] syntax declares a type parameter named T. The cmp.Ordered constraint restricts T to types that support ordering: int, float64, string, and their aliases. The compiler checks the constraint before generating code. If you pass a type that does not satisfy it, the program fails to compile.
Type parameters follow a simple naming convention. Single uppercase letters like T, K, V, or E are standard. They signal "this is a placeholder, not a real type." You will see this pattern everywhere in the standard library and in production code.
What the compiler actually does
When you call Max(1, 2), the compiler looks at the arguments, infers that T must be int, and verifies that int satisfies cmp.Ordered. It then generates a specialized Max_int function in your binary. When you call Max("alpha", "beta"), it generates Max_string. These specialized functions live side by side in the compiled executable.
This process is called monomorphization. It happens entirely at compile time. The runtime never sees a type parameter. It only sees concrete functions with concrete types. This is why generics in Go do not introduce garbage collection pressure or runtime type checks. The tradeoff is slightly larger binaries if you use the same generic function with dozens of different types, but the size increase is usually negligible compared to the rest of your dependencies.
Type inference in Go is deliberately conservative. The compiler only infers type parameters from function arguments. It does not look at return types, and it does not look at how you assign the result. If you write result := Max(1, 2.0), the compiler rejects the call because it cannot pick a single type for T. You must make the types match, or you must explicitly specify the type parameter: Max[float64](1, 2.0).
Realistic usage: mapping over slices
Generics shine when you work with collections. A generic Map function lets you transform a slice of any type into a slice of another type.
package main
import "fmt"
// Map applies a transformation function to each element of a slice.
func Map[T any, U any](s []T, fn func(T) U) []U {
// Pre-allocate the result slice to avoid repeated resizing.
result := make([]U, len(s))
for i, v := range s {
// Apply the user-provided function to each element.
result[i] = fn(v)
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4}
// The compiler infers T as int and U as string.
words := Map(numbers, func(n int) string {
return fmt.Sprintf("item-%d", n)
})
fmt.Println(words)
}
The function declares two type parameters: T for the input slice, and U for the output slice. The any constraint means "any type at all." It is an alias for interface{}, but the name signals intent more clearly. You can pass integers, structs, pointers, or channels. The transformation function fn bridges the two types.
Notice the pre-allocation of result. This is a performance convention that applies to both generic and concrete code. Allocating the exact capacity upfront avoids heap allocations during the loop. The compiler cannot optimize this away for you, so you write it explicitly.
Pitfalls and compiler rules
Generics introduce a few new failure modes. The most common is constraint mismatch. If you write a function that expects cmp.Ordered but pass a custom struct, the compiler rejects the program with type MyStruct does not implement cmp.Ordered. The error message points directly to the constraint violation. You fix it by either changing the constraint or implementing the required methods.
Type inference limits cause friction when you want to reuse a generic function as a value. If you write f := Max, the compiler cannot infer T because there are no arguments yet. You get type parameter T not inferred in call. You must either call it immediately, or bind it to a specific type: f := Max[int].
Another trap is overusing any. When you constrain a type parameter to any, you lose the ability to use operators like <, +, or == on it. The compiler enforces this strictly. If you try to compare two any values with ==, you get invalid operation: operator == not defined on T. You must either tighten the constraint, or use reflection (which you should avoid in performance-sensitive paths).
Generics also do not replace interfaces. Interfaces describe behavior. Generics describe structure. If you need a value that implements io.Reader, you use an interface. If you need a container that holds any type, you use a generic. Mixing them up leads to awkward code. The compiler will complain with cannot use T as type io.Reader in argument if you try to pass a generic type parameter where an interface is expected, unless you explicitly constrain T to io.Reader.
Convention aside: Go developers prefer concrete types for return values and interfaces for parameters. This rule extends to generics. You accept a generic type parameter, but you return a concrete slice or struct. The pattern keeps your API surface predictable and your tests straightforward.
When to reach for generics
Use a generic function when you need the same algorithm to work across multiple types without changing the logic. Use an interface when you care about behavior rather than type structure. Use type assertions when you are working with interface{} values from legacy code or third-party libraries that predate generics. Stick to concrete types when the function only ever handles one type: the simplest thing that works is usually the right thing.
Generics are a compile-time tool. They do not make your code faster at runtime. They do not replace careful design. They remove duplication while preserving type safety. Treat them as a precision instrument, not a silver bullet.