Chapter 13: Generics
Generics
Section titled “Generics”Go 1.18 introduced generics (type parameters), allowing you to write functions and types that work with multiple types while maintaining type safety. This is one of the most significant additions to Go since its creation.
Generics solve a fundamental tension in programming languages: how do you write code that’s both reusable and type-safe? For over a decade, Go required developers to choose between duplicating code for each type or sacrificing type safety with empty interfaces. Generics finally provide a third option.
This chapter covers type parameters, constraints, generic types, and practical patterns for using generics effectively. You’ll learn when generics are appropriate, how to design generic APIs, and common pitfalls to avoid. By the end, you’ll understand not just the syntax, but the design philosophy behind Go’s approach to generics.
Why Generics?
Section titled “Why Generics?”The Problem Before Generics
Section titled “The Problem Before Generics”Before Go 1.18, writing reusable code that worked with multiple types required painful trade-offs. Need a max function that works with integers, floats, and strings? You had three bad options:
Option 1: Code Duplication - Write MaxInt, MaxFloat64, MaxString, etc. This is tedious, error-prone, and bloats your codebase. When you fix a bug in one version, you must remember to fix all others. When you need a new type, you write yet another copy.
Option 2: Use interface{} - Accept interface{} and use type assertions inside. This sacrifices type safety. You can pass incompatible types and get runtime panics instead of compile-time errors. The compiler can’t help you.
Option 3: Code Generation - Generate type-specific code from templates. This works but adds build complexity and makes debugging harder. Generated code clutters repositories and confuses tools.
None of these options are satisfying. Generics eliminate the trade-off: write code once, maintain type safety, no code generation required.
What Generics Enable
Section titled “What Generics Enable”Type-Safe Reusability: Write a function or type once, use it with any type that satisfies the constraints. The compiler verifies type safety at compile time. No runtime type assertions, no panics.
Cleaner APIs: Libraries can provide generic data structures (stacks, queues, sets) without forcing users to choose between type-specific versions or interface{} wrappers. One implementation, any type.
Better Performance: Generic code can be optimized per type. When you call Max[int], the compiler can generate specialized code for integers. With interface{}, every operation goes through interface dispatch.
Functional Programming Patterns: Map, filter, reduce, and other functional utilities are now practical in Go. Previously, writing generic versions required interface{} and reflection. Generics make these patterns natural.
Before generics, you had two options for writing reusable code:
- Use
interface{}(nowany) and lose type safety - Write duplicate code for each type
Type Parameters
Section titled “Type Parameters”Understanding Type Parameters
Section titled “Understanding Type Parameters”Type parameters are Go’s implementation of generics. The syntax [T constraints.Ordered] introduces a type parameter named T that must satisfy the Ordered constraint. You can think of type parameters as function parameters, but for types instead of values.
When you write a generic function, you’re writing a template. The Go compiler generates specialized versions for each type you actually use. Call Max[int]? The compiler creates an integer-specific version. Call Max[float64]? You get a float-specific version. This happens at compile time, so there’s no runtime overhead.
Type parameters can appear in three places:
- Functions:
func Max[T constraints.Ordered](a, b T) T - Types:
type Stack[T any] struct { items []T } - Methods:
func (s *Stack[T]) Push(item T)
The square bracket syntax was carefully chosen to avoid conflicts with existing Go code. It’s visually distinct from function calls and array indexing.
How Type Parameters Work
Section titled “How Type Parameters Work”With generics, you can write a single function that works with any type that satisfies the constraint:
The [T constraints.Ordered] syntax declares a type parameter T that must satisfy the Ordered constraint.
Built-in Constraints
Section titled “Built-in Constraints”Understanding Constraints
Section titled “Understanding Constraints”Constraints define what operations are allowed on type parameters. They answer the question: “What can I do with this generic type T?” Without constraints, you can’t do much - you can’t compare values, add them, or even print them safely.
Go provides built-in constraints for common needs and lets you define custom constraints for specific requirements. Constraints are implemented using interface syntax, but they work differently from traditional interfaces.
The any Constraint
Section titled “The any Constraint”any is an alias for interface{} and allows any type. It’s the most permissive constraint - literally any Go type satisfies it. Use any when you need to store or pass values without operating on them.
The comparable Constraint
Section titled “The comparable Constraint”comparable allows types that support == and !=. This constraint is essential for operations like searching, deduplication, or implementing sets and maps.
All basic types (integers, floats, strings, booleans), pointers, and structs where all fields are comparable satisfy this constraint. Slices, maps, and functions are not comparable and cannot be used with this constraint.
When to use comparable: Anytime you need to check equality. Searching for an element in a slice? Need comparable. Implementing a set? Need comparable. Building a cache with type-safe keys? Need comparable.
Custom Constraints
Section titled “Custom Constraints”Defining Your Own Constraints
Section titled “Defining Your Own Constraints”You can define your own constraints using interface syntax. This is where generics get powerful: you specify exactly what operations your generic code needs, and the compiler enforces it.
Custom constraints use the pipe operator (|) to create type unions. A type union means “any of these types.” So int | float64 means “either int or float64.” Your generic code can only use operations that work on all types in the union.
Why custom constraints matter: The built-in constraints (any, comparable) are useful but limited. Most real-world generic code needs something more specific. Want a function that works with any numeric type? You need a custom constraint that lists all numeric types. Want to operate on types with a specific method? Define a constraint interface with that method.
Custom constraints are how you build domain-specific generic APIs. Instead of accepting any and losing type safety, you accept exactly the types that make sense for your operation.
Type Approximation (~)
Section titled “Type Approximation (~)”Understanding the Tilde Operator
Section titled “Understanding the Tilde Operator”The ~ operator (tilde) is one of the more subtle features of Go generics. It means “this type or any type with this underlying type.” This distinction matters when you have custom types based on built-in types.
Consider type UserID int. The type UserID has int as its underlying type, but it’s not the same as int. Without ~, a constraint int | float64 only accepts int and float64, not UserID. With ~int | ~float64, it accepts int, float64, UserID, and any other type built on those underlying types.
Why Type Approximation Matters
Section titled “Why Type Approximation Matters”Domain-Specific Types: Go encourages creating custom types for domain concepts (type UserID int, type EmailAddress string). Without ~, generic functions couldn’t work with these types. With ~, your generic math functions work with both int and UserID.
Library Compatibility: Libraries often define custom numeric types for clarity or to add methods. Without ~, generic code would reject these types even though they’re just integers underneath. Type approximation makes generics play nicely with this common Go pattern.
When to Use ~: Use ~ in constraints when you want your generic code to work with custom types built on standard types. If you’re building a library, almost always use ~ for numeric constraints. Users shouldn’t have to convert their domain types to primitives just to use your generic function.
Generic Types
Section titled “Generic Types”Creating Generic Data Structures
Section titled “Creating Generic Data Structures”You can create generic structs, interfaces, and methods. This is where generics truly shine - building reusable data structures that work with any type while maintaining full type safety.
Generic types declare type parameters in square brackets after the type name: type Stack[T any] struct { ... }. All methods on that type must include the same type parameters: func (s *Stack[T]) Push(item T). The type parameter becomes part of the type’s identity - Stack[int] and Stack[string] are different types.
Why generic types matter: Before generics, you had to choose: write IntStack, StringStack, UserStack, etc. (duplication), or write Stack that stores interface{} (no type safety). Generic types eliminate this trade-off. One implementation, full type safety, any type.
Common generic data structures include stacks, queues, trees, graphs, caches, and result wrappers. These are perfect candidates for generics because their behavior is type-independent - a stack works the same way regardless of what it stores.
Generic Stack Example
Section titled “Generic Stack Example”Generic Result Type
Section titled “Generic Result Type”Practical Utilities
Section titled “Practical Utilities”Here are some useful generic utility functions:
Type Inference
Section titled “Type Inference”Go can often infer type parameters from the arguments:
When to Use Generics
Section titled “When to Use Generics”Good Use Cases
Section titled “Good Use Cases”Data Structures: Stacks, queues, trees, sets, caches, and other containers are ideal for generics. The behavior is type-independent, and type safety matters.
Utility Functions: Map, filter, reduce, sort, and similar operations benefit from generics. Without generics, these either require duplication or sacrifice type safety.
Type-Safe Wrappers: Result types, Option types, and other wrappers that add semantics to values work beautifully with generics.
Algorithms: Algorithms that work on any comparable or ordered type (searching, sorting, unique-checking) are natural fits for generics.
When to Avoid Generics
Section titled “When to Avoid Generics”Method-Heavy Logic: If your type needs many methods with complex interactions, generics might make the code harder to follow. Sometimes concrete types with clear behavior are better.
Simple Helper Functions: If a function is only called with one type, don’t make it generic “just in case.” Add generics when you actually need multiple types.
Business Logic: Domain-specific business rules often don’t benefit from generics. An Invoice should be an Invoice, not a generic Document[T]. Use concrete types for domain models.
When Interfaces Suffice: If existing interface methods cover your needs, you might not need generics. Don’t replace io.Reader with Reader[T any] - the interface works fine.
Best Practices
Section titled “Best Practices”Start Concrete, Generalize Later: Write concrete code first. When you find yourself duplicating it for multiple types, consider generics. Don’t prematurely generalize.
Name Type Parameters Meaningfully: Use T for single parameters, but use descriptive names for multiple: Map[K comparable, V any] is clearer than Map[T, U any].
Keep Constraints Minimal: Use the weakest constraint that lets your code work. If you only need comparable, don’t require constraints.Ordered.
Document Constraint Requirements: When defining public generic APIs, document what operations the constraints enable. Help users understand what types will work.
Test with Multiple Types: Test your generic code with various types - primitive types, custom types, pointers, structs. Ensure it works as intended across all reasonable types.
Key Takeaways
Section titled “Key Takeaways”- Type parameters
[T constraint]enable generic functions and types - Use
anywhen any type works,comparablewhen you need==/!= - Custom constraints use interface syntax with type unions (
|) - The
~operator matches underlying types (including aliases) - Go infers type parameters when possible - explicit types optional
- Generics maintain full type safety at compile time
- Use generics for reusable data structures and utility functions
Exercise
Section titled “Exercise”Create a generic Set type with Add, Remove, Contains, and Values methods:
Generic Set Implementation
Implement a generic Set type that stores unique values. The Set should support Add, Remove, Contains, and Values operations.
Next up: Chapter 14: Context Package