Chapter 6: Go Generics - Write Once, Use With Any Type
Introduction
You write a function to find the maximum value in a slice of integers. It works perfectly. The next week, you need the same logic for floats. You copy, paste, and change
int to float64. A month later, strings. Copy, paste again.Now you have three nearly identical functions. A bug lurks in one version the fix you applied to the int version never made it to the others. Duplicated code is a maintenance nightmare waiting to happen.
This is the problem generics solve. One function. Any type. Zero duplication.
Why generics matter in real Go systems:
- Data structures: Stacks, queues, trees that work with any element type
- Utility functions: Map, filter, reduce operations across different collections
- Type-safe containers: Caches, pools, registries without
interface{}casts - Algorithm libraries: Sorting, searching, transforming for multiple types
Before Go 1.18, developers chose between code duplication and type safety. Generics eliminate that false choice.
Core Concepts

Go blog diagram 1
Life Before Generics
Pre-1.18 Go developers had two options for type-flexible code:
Option 1: The
interface{} approachgofunc Max(a, b interface{}) interface{} { // How do you compare interface{} values? // Type assertions everywhere, panics at runtime switch av := a.(type) { case int: if bv, ok := b.(int); ok { if av > bv { return av } return bv } // ... repeat for every type ... } return nil }
Problems:
- No compile-time type safety
- Runtime panics on type mismatches
- Verbose, error-prone code
- Performance overhead from type assertions
Option 2: Code generation
go//go:generate genny -in=max.go -out=max_int.go gen "T=int" //go:generate genny -in=max.go -out=max_float64.go gen "T=float64"
Problems:
- Complex build process
- Bloated binaries
- Harder to maintain
- External tooling required
The Generic Solution

Go blog diagram 2
Generics let you write type-parameterized code that the compiler specializes for each type used:
gofunc Max[T int | float64 | string](a, b T) T { if a > b { return a } return b } // Usage: maxInt := Max(10, 20) // Compiler infers T = int maxFloat := Max(3.14, 2.71) // Compiler infers T = float64 maxString := Max("abc", "xyz") // Compiler infers T = string
Benefits:
- Type safety at compile time
- No runtime overhead (types resolved at compile time)
- Single implementation for multiple types
- Clear, readable code
Detailed Explanation: Type Parameters
Anatomy of a Generic Function
gofunc FunctionName[TypeParam Constraint](regularParams) ReturnType { // function body }
- Square brackets
[]: Contain type parameters - Type parameter: A placeholder for a concrete type (commonly
T,K,V) - Constraint: Specifies what types are allowed
Your First Generic Function
go// Filename: first_generic.go package main import "fmt" // Max returns the larger of two ordered values. // T is a type parameter constrained to types that support comparison. func Max[T int | float64 | string](a, b T) T { if a > b { return a } return b } func main() { // The compiler infers T from the arguments fmt.Println("Max int:", Max(10, 20)) fmt.Println("Max float:", Max(3.14, 2.71)) fmt.Println("Max string:", Max("apple", "banana")) // Explicit type parameter (rarely needed) fmt.Println("Explicit:", Max[int](5, 3)) }
Output:
Max int: 20 Max float: 3.14 Max string: banana Explicit: 5
Understanding Type Inference
Go usually infers type parameters from arguments:
go// These are equivalent: result := Max(10, 20) // T inferred as int result := Max[int](10, 20) // T explicit // Sometimes explicit is needed: var x interface{} = 10 var y interface{} = 20 // Max(x, y) won't compile - can't infer T from interface{}
Detailed Explanation: Constraints
What Are Constraints?
A constraint is an interface that specifies what a type parameter must support. The type argument must implement the constraint.
Built-in Constraints
Go provides several built-in constraints:
goimport "golang.org/x/exp/constraints" // any - allows any type func Print[T any](v T) { fmt.Println(v) } // comparable - types that support == and != func Contains[T comparable](slice []T, target T) bool { for _, v := range slice { if v == target { return true } } return false } // constraints.Ordered - types that support < > <= >= func Min[T constraints.Ordered](a, b T) T { if a < b { return a } return b } // constraints.Integer - all integer types // constraints.Float - all float types // constraints.Signed - signed integers // constraints.Unsigned - unsigned integers
Constraint Reference Table
| Constraint | Description | Example Types |
|---|---|---|
any | Any type | Everything |
comparable | Supports ==, != | int, string, pointers, arrays |
constraints.Ordered | Supports <, >, <=, >= | int, float64, string |
constraints.Integer | All integer types | int, int8, int16, uint, etc. |
constraints.Float | All float types | float32, float64 |
constraints.Complex | Complex numbers | complex64, complex128 |
Creating Custom Constraints
Define constraints as interfaces with type elements:
go// Filename: custom_constraints.go package main import "fmt" // Number allows any numeric type type Number interface { int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64 } // Sum adds all numbers in a slice func Sum[T Number](values []T) T { var total T for _, v := range values { total += v } return total } // Stringer constraint using method type Stringer interface { String() string } // PrintAll prints items that have a String method func PrintAll[T Stringer](items []T) { for _, item := range items { fmt.Println(item.String()) } } // Combined constraint: both comparable and has method type ComparableStringer interface { comparable String() string } func main() { ints := []int{1, 2, 3, 4, 5} fmt.Println("Sum of ints:", Sum(ints)) floats := []float64{1.1, 2.2, 3.3} fmt.Println("Sum of floats:", Sum(floats)) }
The Tilde (~) Operator
The
~ operator includes types whose underlying type matches:gotype MyInt int // Without ~: MyInt is NOT allowed type IntOnly interface { int } // With ~: MyInt IS allowed (its underlying type is int) type IntLike interface { ~int } func Double[T IntLike](v T) T { return v * 2 } func main() { var x MyInt = 5 result := Double(x) // Works! MyInt has underlying type int fmt.Println(result) // 10 }
Detailed Explanation: Generic Types
Generic Structs
Create data structures that work with any type:
go// Filename: generic_stack.go package main import "fmt" // Stack is a generic LIFO data structure type Stack[T any] struct { items []T } // Push adds an item to the top func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) } // Pop removes and returns the top item func (s *Stack[T]) Pop() (T, bool) { if len(s.items) == 0 { var zero T // Zero value for type T return zero, false } index := len(s.items) - 1 item := s.items[index] s.items = s.items[:index] return item, true } // Peek returns the top item without removing func (s *Stack[T]) Peek() (T, bool) { if len(s.items) == 0 { var zero T return zero, false } return s.items[len(s.items)-1], true } // Len returns the number of items func (s *Stack[T]) Len() int { return len(s.items) } func main() { // Stack of integers intStack := Stack[int]{} intStack.Push(1) intStack.Push(2) intStack.Push(3) if val, ok := intStack.Pop(); ok { fmt.Println("Popped:", val) // 3 } // Stack of strings stringStack := Stack[string]{} stringStack.Push("hello") stringStack.Push("world") if val, ok := stringStack.Peek(); ok { fmt.Println("Peek:", val) // world } }
Generic Maps and Caches

Go blog diagram 3
go// Filename: generic_cache.go package main import ( "sync" "time" ) // Cache is a generic thread-safe cache type Cache[K comparable, V any] struct { mu sync.RWMutex items map[K]cacheItem[V] } type cacheItem[V any] struct { value V expiresAt time.Time } // NewCache creates an empty cache func NewCache[K comparable, V any]() *Cache[K, V] { return &Cache[K, V]{ items: make(map[K]cacheItem[V]), } } // Set adds or updates an item with a TTL func (c *Cache[K, V]) Set(key K, value V, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.items[key] = cacheItem[V]{ value: value, expiresAt: time.Now().Add(ttl), } } // Get retrieves an item if not expired func (c *Cache[K, V]) Get(key K) (V, bool) { c.mu.RLock() defer c.mu.RUnlock() item, exists := c.items[key] if !exists { var zero V return zero, false } if time.Now().After(item.expiresAt) { var zero V return zero, false } return item.value, true }
Common Patterns
Pattern 1: Map/Transform
Transform each element of a slice:
go// Map applies a function to each element func Map[T, U any](slice []T, fn func(T) U) []U { result := make([]U, len(slice)) for i, v := range slice { result[i] = fn(v) } return result } // Usage: numbers := []int{1, 2, 3, 4, 5} squared := Map(numbers, func(n int) int { return n * n }) // [1, 4, 9, 16, 25] strings := Map(numbers, func(n int) string { return fmt.Sprintf("%d", n) }) // ["1", "2", "3", "4", "5"]
Pattern 2: Filter
Keep elements matching a predicate:
go// Filter keeps elements where predicate returns true func Filter[T any](slice []T, predicate func(T) bool) []T { result := make([]T, 0) for _, v := range slice { if predicate(v) { result = append(result, v) } } return result } // Usage: numbers := []int{1, 2, 3, 4, 5, 6} evens := Filter(numbers, func(n int) bool { return n%2 == 0 }) // [2, 4, 6]
Pattern 3: Reduce
Combine all elements into a single value:
go// Reduce combines elements using a function func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U { result := initial for _, v := range slice { result = fn(result, v) } return result } // Usage: numbers := []int{1, 2, 3, 4, 5} sum := Reduce(numbers, 0, func(acc, n int) int { return acc + n }) // 15 product := Reduce(numbers, 1, func(acc, n int) int { return acc * n }) // 120
Pattern 4: Keys and Values
Extract keys or values from maps:
go// Keys returns all keys from a map func Keys[K comparable, V any](m map[K]V) []K { keys := make([]K, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } // Values returns all values from a map func Values[K comparable, V any](m map[K]V) []V { values := make([]V, 0, len(m)) for _, v := range m { values = append(values, v) } return values }

Go blog diagram 4
When to Use Generics
Good Use Cases
- Data structures: Lists, trees, stacks, queues
- Collection utilities: Map, filter, reduce, find
- Type-safe containers: Caches, pools, registries
- Algorithm libraries: Sorting, searching
- Result/Option types: Generic error handling patterns
When to Avoid Generics
- Single concrete type: If you only ever need
int, just useint - Simple functions: Don't over-engineer simple code
- When interfaces suffice: If behavior (methods) matters more than types
- Reading/writing specific types: IO operations are type-specific
Decision Framework
Do I need the same logic for multiple types? ├── No → Use concrete types └── Yes → Continue Do the types share behavior (methods)? ├── Yes → Consider interfaces first └── No → Continue Do I need compile-time type safety? ├── Yes → Use generics └── No → interface{} might be acceptable
Common Mistakes and Misconceptions
Mistake 1: Overusing Generics
go// OVERKILL: Only one type needed func ProcessUsers[T User](users []T) { ... } // SIMPLER: Just use the concrete type func ProcessUsers(users []User) { ... }
Mistake 2: Wrong Constraint
go// BROKEN: any doesn't support + func Sum[T any](a, b T) T { return a + b // Compile error! } // FIXED: Proper numeric constraint func Sum[T constraints.Integer | constraints.Float](a, b T) T { return a + b }
Mistake 3: Confusing Type Parameter Names
go// CONFUSING: What do these mean? func Process[A, B, C, D any](...) { ... } // CLEARER: Conventional or descriptive names func Process[K comparable, V any](...) { ... } // For map-like structures func Transform[In, Out any](input In) Out { ... } // For transformations
Mistake 4: Forgetting Zero Values
go// PROBLEMATIC: What to return when empty? func First[T any](slice []T) T { if len(slice) == 0 { // Can't return nil for non-pointer T! } return slice[0] } // BETTER: Return (value, ok) pattern func First[T any](slice []T) (T, bool) { if len(slice) == 0 { var zero T return zero, false } return slice[0], true }
Performance Considerations
Compilation
The Go compiler generates specialized code for each type combination used. This means:
- No runtime type assertions (unlike
interface{}) - Type-specific optimizations apply
- Binary size may increase with many instantiations
Runtime
go// Generics: Compiled to specialized code func Sum[T constraints.Integer](values []T) T { ... } Sum([]int{1, 2, 3}) // Uses int-specialized code // interface{}: Runtime type assertions func SumInterface(values []interface{}) interface{} { ... } // Slower due to boxing/unboxing
| Approach | Type Safety | Runtime Cost | Memory |
|---|---|---|---|
| Generics | Compile-time | Minimal | Normal |
| interface{} | Runtime | Higher (assertions) | Higher (boxing) |
| Code generation | Compile-time | Minimal | Higher (code duplication) |
Summary
Key takeaways from this chapter:
-
Type parameters use square brackets:
func Name[T Constraint]() -
Constraints define allowed types: Use
any,comparable, or custom interfaces -
Generics work with structs too:
type Stack[T any] struct {...} -
Type inference reduces verbosity: Often don't need explicit type arguments
-
The ~ operator includes derived types:
~intincludestype MyInt int -
Use generics for reusable logic: Data structures, utilities, transformations
-
Don't overuse: Concrete types and interfaces are still valuable
Interview Questions
-
What problem do generics solve in Go? Why were they added in Go 1.18?
-
Explain the difference between a type parameter and a constraint.
-
What is the
anyconstraint and when would you use it versus more specific constraints? -
Write a generic function that finds the index of an element in a slice. What constraint do you need?
-
Explain the difference between
intand~intin a constraint. When would you use each? -
How do generics differ from using
interface{}for type flexibility? What are the trade-offs? -
Can generic functions have methods? Can generic types?
-
How does the Go compiler handle generics? Are they implemented through type erasure or monomorphization?
-
Write a generic
Result[T]type that can hold either a value or an error. -
When would you choose an interface over generics? Give an example.
-
Explain why you can't use
<operator with thecomparableconstraint. -
How would you implement a generic set data structure?
-
What happens when you create a zero value for a generic type?
-
Can you have multiple type parameters? How would you constrain them differently?
-
Describe a scenario where generics would make code worse, not better.