How do generics work in Go

Go generics allow writing reusable, type-safe code by defining type parameters that the compiler specializes at compile time.

When copy-paste stops working

You write a function to sum a slice of integers. It works. Two days later, you need to sum a slice of floats. You copy the function, rename it, and change int to float64. A week later, you have a Duration type and you want to sum those too. You're copying code again. The codebase is bloated with identical logic that only differs by type.

This duplication is fragile. If you fix a bug in the integer version, you have to remember to fix it in the float and duration versions. You also lose type safety if you accidentally pass a float slice to the integer function. Go generics solve this by letting you write the logic once and letting the compiler generate the type-specific versions.

Generics as compile-time templates

Generics introduce type parameters. A type parameter is a placeholder for a concrete type. You define the placeholder in square brackets after the function or type name. When you use the generic with a real type, the compiler replaces the placeholder with that type and generates specialized code.

Think of a generic function as a form letter. You write "Dear [Name]" once. When you send the letter to Alice, you fill in "Alice". When you send it to Bob, you fill in "Bob". The form letter is the generic definition. The printed letters are the instantiated functions. The compiler handles the filling-in process. It happens at compile time, so there is no runtime overhead. The generated code runs as fast as hand-written code.

Constraints control which types can fill the placeholder. You can restrict a type parameter to specific types like int and float64, or to a set of types that share a property like comparable. Constraints ensure type safety. The compiler rejects calls that violate the constraints.

Minimal example

Here is a generic sum function. It works for any type that supports addition and has a zero value.

// Sum calculates the total of a slice.
// T is constrained to int or float64 to ensure arithmetic support.
func Sum[T int | float64](slice []T) T {
	// var total T initializes total to the zero value of T.
	// For int this is 0, for float64 this is 0.0.
	var total T
	for _, v := range slice {
		// The compiler checks that T supports += at compile time.
		// This check fails if T is a type without arithmetic operators.
		total += v
	}
	return total
}

You call the function with a concrete slice. The compiler infers the type parameter from the argument.

ints := []int{1, 2, 3}
fmt.Println(Sum(ints)) // Output: 6

floats := []float64{1.5, 2.5}
fmt.Println(Sum(floats)) // Output: 4

The compiler generates two versions of Sum. One version works with int, the other with float64. Both versions exist in the binary. The call site uses the correct version based on the argument type.

What the compiler does

When the compiler sees Sum([]int{1, 2, 3}), it infers T as int. It checks that int satisfies the constraint int | float64. The check passes. The compiler then creates a copy of the Sum function where every occurrence of T is replaced by int. This process is called monomorphization.

The generated code looks like this:

func Sum_int(slice []int) int {
	var total int
	for _, v := range slice {
		total += v
	}
	return total
}

The compiler does the same for float64. The binary contains both Sum_int and Sum_float64. There is no generic dispatch at runtime. The call is a direct function call to the specialized version. This keeps performance high.

If you try to call Sum with a type that violates the constraint, the compiler rejects the program. Passing a string slice fails with invalid type argument: string does not implement int | float64. The error message tells you exactly which constraint failed.

Generics also support type inference for multiple parameters. If a function has two type parameters, the compiler tries to infer both from the arguments. If inference is ambiguous, you can provide explicit type arguments.

// Pair creates a tuple of two values.
// K and V are inferred from the arguments.
func Pair[K, V any](k K, v V) (K, V) {
	return k, v
}

k, v := Pair("key", 42) // K is string, V is int.

Realistic example: a generic Set

Data structures are the most common use case for generics. A set holds unique values. The element type varies, but the structure is the same. A generic Set type lets you reuse the implementation for any comparable type.

// Set stores unique elements of type T.
// The comparable constraint ensures T can be a map key.
type Set[T comparable] struct {
	// Using struct{} as the value minimizes memory usage.
	// The map key holds the data; the value is just a marker.
	items map[T]struct{}
}

// NewSet creates a new Set with pre-allocated capacity.
func NewSet[T comparable](cap int) *Set[T] {
	// Initialize the map to avoid nil map panics.
	// Writing to a nil map causes a runtime panic.
	return &Set[T]{items: make(map[T]struct{}, cap)}
}

// Add inserts v into the set.
func (s *Set[T]) Add(v T) {
	// Map assignment handles existence checks automatically.
	// If v exists, the assignment is a no-op.
	s.items[v] = struct{}{}
}

// Contains returns true if v is in the set.
func (s *Set[T]) Contains(v T) bool {
	// The blank identifier discards the value, checking only presence.
	// This is the standard idiom for map lookups in Go.
	_, ok := s.items[v]
	return ok
}

The receiver name s matches the type Set. This follows Go convention for receiver naming. The constraint comparable ensures that T can be used as a map key. Only comparable types can be map keys.

You use the set with any comparable type.

// StringSet holds unique strings.
stringSet := NewSet[string](10)
stringSet.Add("hello")
stringSet.Add("world")
fmt.Println(stringSet.Contains("hello")) // Output: true

// IntSet holds unique integers.
intSet := NewSet[int](10)
intSet.Add(42)
fmt.Println(intSet.Contains(99)) // Output: false

The compiler generates a Set[string] type and a Set[int] type. Each type has its own methods. The methods are specialized for the element type.

Constraints and the comparable gotcha

Constraints define what types are allowed. The comparable constraint is a built-in constraint that matches all comparable types. Comparable types include primitives, pointers, channels, and structs with comparable fields.

Slices, maps, and functions are not comparable. You cannot use them with comparable. If you try to create a Set[[]int], the compiler rejects it with invalid type argument: []int does not implement comparable. The error message is clear. Slices cannot be map keys because their contents can change. Comparing slices requires checking every element, which is not supported by the == operator.

If you need a set of slices, you must use a different approach. You can wrap the slice in a struct and implement a custom comparison, or use a generic constraint that allows non-comparable types and manages equality manually.

The tilde operator ~ matches underlying types. A constraint like interface{ ~int } accepts int and any type with an underlying type of int, such as type MyInt int. Without the tilde, MyInt would not satisfy interface{ int }. Use the tilde when you want to support type aliases.

// ToInt converts a type with underlying int to int.
// The ~ allows MyInt to satisfy the constraint.
func ToInt[T interface{ ~int }](t T) int {
	return int(t)
}

Generics use type sets for constraints. A type set is a collection of types. Constraints can be defined using interface syntax or type set syntax. The compiler treats them equivalently. Prefer the interface syntax for readability.

Type inference limits

Type inference works for function calls and struct literals in many cases. It does not work for variable declarations. The compiler cannot infer type parameters from a variable declaration alone because there is no value to inspect.

// This fails. The compiler cannot infer T.
// var s Set
// Error: type argument required

// You must specify the type explicitly.
var s Set[int]

If you need a variable of a generic type, provide the type arguments. You can also use new to allocate a generic type.

// new(Set[int]) returns a *Set[int].
s := new(Set[int])

Type inference also has limits with nested generics. If a function returns a generic type, the compiler might not infer the type parameters from the return value. You may need to specify them explicitly at the call site.

// MakeSet returns a Set of type T.
func MakeSet[T comparable]() *Set[T] {
	return NewSet[T](0)
}

// Inference works here because the variable type is known.
var s *Set[int] = MakeSet()

// Explicit type argument is safer when the variable type is not declared.
s := MakeSet[int]()

Pitfalls and errors

Generic code can trigger subtle errors. The compiler catches most issues, but the error messages can be verbose.

Using a type parameter in a non-type context fails. You cannot use T as a value. You can only use T as a type.

// This fails. T is a type, not a value.
// func Bad[T any]() {
//     var x = T
// }
// Error: type parameter T cannot be used as value

You can create a zero value of T using var t T or new(T). You cannot use struct literal syntax like T{} unless T is constrained to a struct type.

Constraints can become complex. If a constraint requires multiple methods and type sets, the code can be hard to read. Keep constraints simple. If a constraint grows too large, consider whether an interface would be clearer.

Binary size can increase with generics. Each instantiation adds code to the binary. For small functions, the increase is negligible. For large functions with many instantiations, the binary size can grow. Profile your binary if size is a concern. In most applications, the duplication is worth the size trade-off.

When to use generics

Generics reduce duplication and improve type safety. They are not a replacement for interfaces or reflection. Choose the right tool based on the problem.

Use a generic function when you need the same logic for multiple types and the types share a structural constraint like comparable or arithmetic operations.

Use a generic type when you are building a data structure like a list, map, or cache where the element type varies but the structure remains identical.

Use an interface when you care about behavior rather than type. If the code calls methods, prefer an interface over a generic constraint. Interfaces allow different types to satisfy the contract through implementation.

Use reflection when you must inspect types at runtime or work with types that are unknown at compile time. Accept the performance cost and loss of type safety. Reflection is useful for serialization and testing, but avoid it in performance-critical paths.

Use copy-paste when the logic differs slightly between types or the constraint becomes so complex that the generic code is harder to read than two simple functions. Simplicity wins.

Generics are a tool for reducing duplication. Interfaces are a tool for expressing behavior. Reflection is a tool for runtime inspection. Pick the tool that matches the problem.

Where to go next