Skip to content

Chapter 13: 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.

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.

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:

  1. Use interface{} (now any) and lose type safety
  2. Write duplicate code for each type

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:

  1. Functions: func Max[T constraints.Ordered](a, b T) T
  2. Types: type Stack[T any] struct { items []T }
  3. 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.

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.

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.

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.

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.

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.

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.

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.

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.

Here are some useful generic utility functions:

Go can often infer type parameters from the arguments:

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.

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.

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.

  1. Type parameters [T constraint] enable generic functions and types
  2. Use any when any type works, comparable when you need ==/!=
  3. Custom constraints use interface syntax with type unions (|)
  4. The ~ operator matches underlying types (including aliases)
  5. Go infers type parameters when possible - explicit types optional
  6. Generics maintain full type safety at compile time
  7. Use generics for reusable data structures and utility functions

Create a generic Set type with Add, Remove, Contains, and Values methods:

Generic Set Implementation

medium

Implement a generic Set type that stores unique values. The Set should support Add, Remove, Contains, and Values operations.


Chapter in progress
0 / 14 chapters completed

Next up: Chapter 14: Context Package