Go Generics: Practical Patterns and Constraints
Learn practical Go generics patterns including type constraints, generic data structures, utility functions, and when to use generics vs interfaces.
What you'll learn
- ✓Write generic functions with type constraints
- ✓Build reusable generic data structures
- ✓Define custom constraint interfaces
- ✓Know when generics are the right tool
Prerequisites
- •Go interfaces and structs
- •Basic type system knowledge
- •Go slices and maps
Go introduced generics in version 1.18. They let you write functions and types that work with multiple types while maintaining type safety. Unlike interfaces, which provide runtime polymorphism, generics give you compile-time type checking with zero runtime overhead. This guide focuses on practical patterns you will actually use.
Generic Functions: The Basics
A generic function declares type parameters in square brackets before the regular parameters.
package main
import "fmt"
// Min returns the smaller of two values.
// The comparable and ordered constraints come from the constraints package.
func Min[T interface{ ~int | ~float64 | ~string }](a, b T) T {
if a < b {
return a
}
return b
}
// Contains checks if a slice contains a value.
func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}
// Map transforms a slice by applying a function to each element.
func Map[T any, 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
}
// Filter returns elements that satisfy a predicate.
func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
func main() {
fmt.Println(Min(3, 7)) // 3
fmt.Println(Min(3.14, 2.71)) // 2.71
fmt.Println(Min("alpha", "beta")) // alpha
nums := []int{1, 2, 3, 4, 5}
fmt.Println(Contains(nums, 3)) // true
fmt.Println(Contains(nums, 6)) // false
doubled := Map(nums, func(n int) int { return n * 2 })
fmt.Println(doubled) // [2 4 6 8 10]
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println(evens) // [2 4]
}
The constraints Package
The golang.org/x/exp/constraints package (and some built-in constraints) provides common type constraints.
import "golang.org/x/exp/constraints"
// Ordered includes all types that support < > <= >=
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
// Integer includes all integer types
func Sum[T constraints.Integer](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
// Float includes float32 and float64
func Average[T constraints.Float](nums []T) T {
if len(nums) == 0 {
return 0
}
var sum T
for _, n := range nums {
sum += n
}
return sum / T(len(nums))
}
Custom Constraints
Define your own constraints as interfaces with type elements.
// Number includes all numeric types.
type Number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
~float32 | ~float64
}
// Stringer requires a String() method.
type Stringer interface {
String() string
}
// Validator requires a Validate() method.
type Validator interface {
Validate() error
}
// Entity requires both an ID method and validation.
type Entity interface {
Validator
GetID() string
}
func ValidateAll[T Validator](items []T) error {
for i, item := range items {
if err := item.Validate(); err != nil {
return fmt.Errorf("item %d: %w", i, err)
}
}
return nil
}
The tilde ~ in type constraints means “underlying type.” ~int matches int and any named type whose underlying type is int, like type UserID int.
Generic Data Structures
Generics shine when building reusable data structures.
// Stack is a generic LIFO data structure.
type Stack[T any] struct {
items []T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{}
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
last := len(s.items) - 1
item := s.items[last]
s.items = s.items[:last]
return item, true
}
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
}
func (s *Stack[T]) Len() int {
return len(s.items)
}
A more practical example is a generic result type for error handling.
// Result represents a value or an error.
type Result[T any] struct {
value T
err error
}
func Ok[T any](value T) Result[T] {
return Result[T]{value: value}
}
func Err[T any](err error) Result[T] {
return Result[T]{err: err}
}
func (r Result[T]) Unwrap() (T, error) {
return r.value, r.err
}
func (r Result[T]) IsOk() bool {
return r.err == nil
}
func (r Result[T]) Map(fn func(T) T) Result[T] {
if r.err != nil {
return r
}
return Ok(fn(r.value))
}
func (r Result[T]) OrElse(defaultValue T) T {
if r.err != nil {
return defaultValue
}
return r.value
}
Generic Repository Pattern
Generics can reduce boilerplate in CRUD operations.
type Repository[T Entity] struct {
db *sql.DB
table string
}
func NewRepository[T Entity](db *sql.DB, table string) *Repository[T] {
return &Repository[T]{db: db, table: table}
}
func (r *Repository[T]) FindByID(ctx context.Context, id string) (*T, error) {
query := fmt.Sprintf("SELECT * FROM %s WHERE id = $1", r.table)
row := r.db.QueryRowContext(ctx, query, id)
var entity T
if err := scanEntity(row, &entity); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &entity, nil
}
func (r *Repository[T]) Create(ctx context.Context, entity *T) error {
if err := (*entity).Validate(); err != nil {
return fmt.Errorf("validation: %w", err)
}
// Insert logic here
return nil
}
Generic Concurrency Helpers
Generic functions work well for concurrent utility functions.
// FanOut runs fn concurrently for each input and collects results.
func FanOut[T any, U any](ctx context.Context, inputs []T, fn func(context.Context, T) (U, error)) ([]U, error) {
results := make([]U, len(inputs))
g, ctx := errgroup.WithContext(ctx)
for i, input := range inputs {
i, input := i, input
g.Go(func() error {
result, err := fn(ctx, input)
if err != nil {
return err
}
results[i] = result
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
// Usage
urls := []string{"https://go.dev", "https://pkg.go.dev"}
pages, err := FanOut(ctx, urls, func(ctx context.Context, url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
return string(body), err
})
Maps and Slices Utility Package
Go 1.21 added the maps and slices packages with generic utilities. Here are similar patterns you can build.
// Keys returns the keys of 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 the values of 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
}
// GroupBy groups slice elements by a key function.
func GroupBy[T any, K comparable](items []T, keyFn func(T) K) map[K][]T {
result := make(map[K][]T)
for _, item := range items {
key := keyFn(item)
result[key] = append(result[key], item)
}
return result
}
// Reduce reduces a slice to a single value.
func Reduce[T any, U any](slice []T, initial U, fn func(U, T) U) U {
result := initial
for _, v := range slice {
result = fn(result, v)
}
return result
}
When to Use Generics vs Interfaces
Generics and interfaces solve different problems. Here is how to decide.
Use generics when you need the same algorithm to work with different types and want compile-time type safety. Collection operations, data structures, and utility functions are good candidates.
Use interfaces when you need runtime polymorphism, different types need different behavior behind a common API, or you are defining a contract that external code implements.
// Generics: same logic, different types
func SortSlice[T constraints.Ordered](s []T) {
slices.Sort(s)
}
// Interface: different behavior behind same contract
type Storage interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte) error
}
type RedisStorage struct { /* ... */ }
type S3Storage struct { /* ... */ }
Avoid generics when the code only works with one type, when any is the constraint (usually means you should use any directly or an interface), or when it makes the code harder to read without a clear benefit.
Wrapping Up
Go generics eliminate boilerplate for type-safe utility functions, data structures, and concurrent helpers. Use type constraints to specify what operations your generic code needs. Define custom constraints as interfaces with type elements. Reach for generics when you find yourself writing the same function for multiple types. But keep your defaults: use concrete types when you know the type, interfaces when you need polymorphism, and generics when you need type-safe code reuse. The best generic code is the code that makes its callers simpler and more type-safe.
Related articles
- Go Go Generics Deep Dive
How Go's generics work in practice: type parameters, constraints, the constraints package, and where generics shine versus interfaces or code generation.
- Go Dependency Injection Patterns in Go
Learn practical dependency injection in Go using interfaces, constructor injection, and functional options without heavy frameworks.
- Go Struct Embedding and Composition Patterns in Go
Learn Go composition over inheritance with struct embedding, interface embedding, and practical patterns for clean architecture.
- Go Real-World Go Generics Examples Beyond Tutorials
Practical Go generics patterns for production code: type-safe collections, result types, repository layers, and functional helpers.