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

Go blog diagram 1

Life Before Generics

Pre-1.18 Go developers had two options for type-flexible code:
Option 1: The interface{} approach
go
func 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

Go blog diagram 2

Generics let you write type-parameterized code that the compiler specializes for each type used:
go
func 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

go
func 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:
go
import "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

ConstraintDescriptionExample Types
anyAny typeEverything
comparableSupports ==, !=int, string, pointers, arrays
constraints.OrderedSupports <, >, <=, >=int, float64, string
constraints.IntegerAll integer typesint, int8, int16, uint, etc.
constraints.FloatAll float typesfloat32, float64
constraints.ComplexComplex numberscomplex64, 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:
go
type 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 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

Go blog diagram 4

When to Use Generics

Good Use Cases

  1. Data structures: Lists, trees, stacks, queues
  2. Collection utilities: Map, filter, reduce, find
  3. Type-safe containers: Caches, pools, registries
  4. Algorithm libraries: Sorting, searching
  5. Result/Option types: Generic error handling patterns

When to Avoid Generics

  1. Single concrete type: If you only ever need int, just use int
  2. Simple functions: Don't over-engineer simple code
  3. When interfaces suffice: If behavior (methods) matters more than types
  4. 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
ApproachType SafetyRuntime CostMemory
GenericsCompile-timeMinimalNormal
interface{}RuntimeHigher (assertions)Higher (boxing)
Code generationCompile-timeMinimalHigher (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: ~int includes type MyInt int
  • Use generics for reusable logic: Data structures, utilities, transformations
  • Don't overuse: Concrete types and interfaces are still valuable

Interview Questions

  1. What problem do generics solve in Go? Why were they added in Go 1.18?
  2. Explain the difference between a type parameter and a constraint.
  3. What is the any constraint and when would you use it versus more specific constraints?
  4. Write a generic function that finds the index of an element in a slice. What constraint do you need?
  5. Explain the difference between int and ~int in a constraint. When would you use each?
  6. How do generics differ from using interface{} for type flexibility? What are the trade-offs?
  7. Can generic functions have methods? Can generic types?
  8. How does the Go compiler handle generics? Are they implemented through type erasure or monomorphization?
  9. Write a generic Result[T] type that can hold either a value or an error.
  10. When would you choose an interface over generics? Give an example.
  11. Explain why you can't use < operator with the comparable constraint.
  12. How would you implement a generic set data structure?
  13. What happens when you create a zero value for a generic type?
  14. Can you have multiple type parameters? How would you constrain them differently?
  15. Describe a scenario where generics would make code worse, not better.
All Blogs
Tags:golanggenericstype-parametersconstraints