Go Generics Best Practices and Common Patterns

Use Go generics with type parameters and constraints to create reusable, type-safe functions and data structures without code duplication.

The copy-paste trap

You write a function that finds the maximum value in a slice. It works perfectly for int. Two days later you need the same logic for float64. You copy the function, rename it, change the type signature, and paste it into the same file. A month later you realize you also need it for int64 and uint32. Your file now contains four nearly identical functions. The logic is identical. Only the type names differ. You are maintaining four copies of the same bug waiting to happen.

Go used to force this pattern. The language prioritized simplicity and explicitness over abstraction. If you wanted type flexibility, you reached for interface{} and runtime type assertions. That approach works, but it pushes type checking to runtime and clutters your code with type switches. Go 1.18 introduced generics to solve this exact problem. Generics let you write the logic once and let the compiler generate type-safe versions for every type you actually use.

Generics are compile-time templates, not runtime magic.

How generics actually work

Think of a generic function like a cookie cutter. You design the shape once. When you press it into dough, you get a cookie. The cutter does not change. The dough does. In Go, the cutter is your function signature with a type parameter. The dough is the concrete type you pass at the call site. The compiler stamps out a separate version of the function for each concrete type you use.

The key difference from languages like C++ or Java is that Go's generics are intentionally constrained. You cannot write a generic function that accepts absolutely anything and then call arbitrary methods on it. You must declare a constraint. The constraint is a contract that says exactly what operations are allowed on the type parameter. If a type does not satisfy the contract, the compiler rejects the code before it runs.

This design keeps Go's type system predictable. You never get mysterious runtime panics because a generic function tried to call a method that does not exist. The compiler catches it during the build step.

Constraints are contracts. Define them tightly, and the compiler becomes your safety net.

A minimal example

The syntax centers on square brackets placed before the function name. Inside the brackets you declare the type parameter and its constraint. The constraint is usually an interface that lists either a set of concrete types or a set of methods.

package main

import "fmt"

// Number restricts T to signed integers and floating point types.
// This prevents callers from passing strings or structs.
type Number interface {
	int | int32 | int64 | float32 | float64
}

// Max returns the larger of two values of type T.
// The constraint ensures both values support the < operator.
func Max[T Number](a, b T) T {
	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 float64 here.
	fmt.Println(Max(1.5, 2.5))
}

The | operator inside the interface creates a type set. It tells the compiler that T can be any one of those five types. The function body can only use operations that all five types share. Comparison operators like < work for all of them. Arithmetic operators like + also work. If you tried to call .Len() inside Max, the compiler would reject it because int does not have a Len method.

Generics are compile-time templates, not runtime magic.

What the compiler does behind the scenes

When you call Max(1, 2), the compiler performs a process called monomorphization. It looks at the argument types, matches them against the Number constraint, and generates a concrete func Max(int, int) int. It does the same for Max(1.5, 2.5), generating func Max(float64, float64) float64. These generated functions exist only in your compiled binary. There is no generic wrapper at runtime. There is no reflection overhead. The generated code runs exactly as fast as a hand-written concrete function.

The constraint acts as a gatekeeper during this generation step. If you call Max("a", "b"), the compiler checks whether string satisfies Number. It does not. The build fails with string does not satisfy Number (missing method in type set). The error happens at compile time, not when the program runs.

You can also constrain by methods instead of concrete types. This is useful when you want to work with custom types that implement a specific behavior.

package main

import "fmt"

// Stringer requires any type that implements a String() method.
// This mirrors the standard library's fmt.Stringer interface.
type Stringer interface {
	String() string
}

// Describe formats a value using its own String method.
// The constraint guarantees the method exists at compile time.
func Describe[T Stringer](v T) string {
	return fmt.Sprintf("Value: %s", v.String())
}

type Temperature struct {
	Celsius float64
}

// String returns the temperature in Celsius.
// This method satisfies the Stringer constraint.
func (t Temperature) String() string {
	return fmt.Sprintf("%.1f°C", t.Celsius)
}

func main() {
	// The compiler verifies Temperature satisfies Stringer.
	fmt.Println(Describe(Temperature{Celsius: 23.5}))
}

Method constraints let you write generic code that works with your own domain types. You do not need to modify the standard library. You only need to ensure your types implement the required methods.

The compiler does the heavy lifting. Your binary pays no price for flexibility.

A realistic pattern: type-safe caching

Real Go code rarely uses single-type generics in isolation. Production systems usually need maps, slices, or caches that store heterogeneous keys and values. A generic cache is a common pattern because it eliminates the boilerplate of writing separate caches for every entity type.

package main

import (
	"fmt"
	"sync"
)

// Cache stores key-value pairs with thread-safe access.
// K must be comparable so it can be used as a map key.
// V can be any type, allowing flexible value storage.
type Cache[K comparable, V any] struct {
	mu    sync.Mutex
	items map[K]V
}

// NewCache creates a ready-to-use cache instance.
// The type parameters are inferred from the first call to Set or Get.
func NewCache[K comparable, V any]() *Cache[K, V] {
	return &Cache[K, V]{
		items: make(map[K]V),
	}
}

// Set adds or updates a key-value pair.
// The mutex protects concurrent map writes.
func (c *Cache[K, V]) Set(key K, value V) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.items[key] = value
}

// Get retrieves a value by key.
// It returns the value and a boolean indicating presence.
func (c *Cache[K, V]) Get(key K) (V, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	val, ok := c.items[key]
	return val, ok
}

func main() {
	// The compiler infers K as string and V as int.
	userCache := NewCache[string, int]()
	userCache.Set("alice", 42)
	userCache.Set("bob", 17)
	
	// Type-safe retrieval without type assertions.
	if age, found := userCache.Get("alice"); found {
		fmt.Printf("alice is %d\n", age)
	}
}

Notice the receiver signature: (c *Cache[K, V]). Go convention keeps receiver names short and consistent with the type. You will rarely see (this *Cache[K, V]) or (self *Cache[K, V]) in idiomatic code. The compiler treats generic methods exactly like concrete methods. The type parameters are resolved when the struct is instantiated, not when the method is called.

The comparable constraint is a built-in type set that includes all types that support == and !=. Maps in Go require comparable keys. If you remove comparable and try to use a slice or map as K, the compiler rejects it with invalid operation: operator == not defined on K (variable of type K). This error saves you from runtime panics that would otherwise crash your server.

Constraints are contracts. Define them tightly, and the compiler becomes your safety net.

Where constraints bite back

Generics are powerful, but they are not a replacement for careful design. The most common mistake is over-constraining. Developers often write any when they actually need specific behavior, then fall back to type assertions inside the function. That defeats the purpose of generics. If you need to call a method, declare it in the constraint. If you need arithmetic, use a type set or the constraints package from the standard library.

Another trap is assuming generics work seamlessly with all standard library functions. Many older functions expect concrete types. You cannot pass a generic slice to sort.Slice without wrapping it in a concrete type or using a generic adapter. The standard library is gradually adopting generics, but the transition is incremental.

The compiler will reject vague constraints before you ship broken code.

When you define a generic struct, remember that you cannot define a generic method on a non-generic type. The type parameters must be declared at the type level. You also cannot use type parameters in interface method signatures directly. Interfaces and generics serve different purposes. Interfaces describe behavior across unrelated types. Generics describe structure across related types. Mixing them incorrectly leads to verbose workarounds.

The community convention remains clear: accept interfaces, return structs. Generics do not change this rule. If a function only needs to read from an io.Reader, take an io.Reader. Do not make the function generic just to avoid an interface. Interfaces are cheap. Generics add compile-time complexity. Use the right tool for the job.

When to reach for generics

Use generics when you need type-safe collections or algorithms that work across multiple types. Use any with type assertions when you need runtime polymorphism or dynamic behavior that cannot be known at compile time. Use code generation when the logic differs significantly per type and generics would force awkward workarounds. Use plain concrete types when you only ever need one type: the simplest thing that works is usually the right thing.

Pick the tool that matches your data, not the one that looks clever.

Where to go next