DEV Community

Cover image for Go Generics: Use Cases and Patterns
Rost
Rost

Posted on • Originally published at glukhov.org

Go Generics: Use Cases and Patterns

Generics in Go represent one of the most significant language features added since Go 1.0. Introduced in Go 1.18, generics enable you to write type-safe, reusable code that works with multiple types without sacrificing performance or code clarity.

This article explores practical use cases, common patterns, and best practices for leveraging generics in your Go programs.
If you're new to Go or need a refresher on the fundamentals, check out our Go Cheatsheet for essential language constructs and syntax.

Understanding Go Generics

Generics in Go allow you to write functions and types that are parameterized by type parameters. This eliminates the need for code duplication when you need the same logic to work with different types, while maintaining compile-time type safety.

Basic Syntax

The syntax for generics uses square brackets to declare type parameters:

// Generic function
func Max[T comparable](a, b T) T {
    if a > b {
        return a
    }
    return b
}

// Usage
maxInt := Max(10, 20)
maxString := Max("apple", "banana")
Enter fullscreen mode Exit fullscreen mode

Type Constraints

Type constraints specify what types can be used with your generic code:

  • any: Any type (equivalent to interface{})
  • comparable: Types that support == and != operators
  • Custom interface constraints: Define your own requirements
// Using a custom constraint
type Numeric interface {
    int | int8 | int16 | int32 | int64 | 
    uint | uint8 | uint16 | uint32 | uint64 | 
    float32 | float64
}

func Sum[T Numeric](numbers []T) T {
    var sum T
    for _, n := range numbers {
        sum += n
    }
    return sum
}
Enter fullscreen mode Exit fullscreen mode

Common Use Cases

1. Generic Data Structures

One of the most compelling use cases for generics is creating reusable data structures:

// Generic Stack
type Stack[T any] struct {
    items []T
}

func NewStack[T any]() *Stack[T] {
    return &Stack[T]{items: make([]T, 0)}
}

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
    }
    item := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return item, true
}

// Usage
intStack := NewStack[int]()
intStack.Push(42)
intStack.Push(100)

stringStack := NewStack[string]()
stringStack.Push("hello")
Enter fullscreen mode Exit fullscreen mode

2. Slice Utilities

Generics make it easy to write reusable slice manipulation functions:

// Map function
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
}

// Filter function
func Filter[T any](slice []T, fn func(T) bool) []T {
    var result []T
    for _, v := range slice {
        if fn(v) {
            result = append(result, v)
        }
    }
    return result
}

// Reduce 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}
doubled := Map(numbers, func(n int) int { return n * 2 })
evens := Filter(numbers, func(n int) bool { return n%2 == 0 })
sum := Reduce(numbers, 0, func(acc, n int) int { return acc + n })
Enter fullscreen mode Exit fullscreen mode

3. Generic Map Utilities

Working with maps becomes more type-safe with generics:

// Get map keys as a slice
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
}

// Get map values as a slice
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
}

// Safe map get with default value
func GetOrDefault[K comparable, V any](m map[K]V, key K, defaultValue V) V {
    if v, ok := m[key]; ok {
        return v
    }
    return defaultValue
}
Enter fullscreen mode Exit fullscreen mode

4. Generic Option Pattern

The Option pattern becomes more elegant with generics:

type Option[T any] struct {
    value *T
}

func Some[T any](value T) Option[T] {
    return Option[T]{value: &value}
}

func None[T any]() Option[T] {
    return Option[T]{value: nil}
}

func (o Option[T]) IsSome() bool {
    return o.value != nil
}

func (o Option[T]) IsNone() bool {
    return o.value == nil
}

func (o Option[T]) Unwrap() T {
    if o.value == nil {
        panic("attempted to unwrap None value")
    }
    return *o.value
}

func (o Option[T]) UnwrapOr(defaultValue T) T {
    if o.value == nil {
        return defaultValue
    }
    return *o.value
}
Enter fullscreen mode Exit fullscreen mode

Advanced Patterns

Constraint Composition

You can compose constraints to create more specific requirements:

type Addable interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64 | string
}

type Multiplicable interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64
}

type Numeric interface {
    Addable
    Multiplicable
}

func Multiply[T Multiplicable](a, b T) T {
    return a * b
}
Enter fullscreen mode Exit fullscreen mode

Generic Interfaces

Interfaces can also be generic, enabling powerful abstractions:

type Repository[T any, ID comparable] interface {
    FindByID(id ID) (T, error)
    Save(entity T) error
    Delete(id ID) error
    FindAll() ([]T, error)
}

// Implementation
type InMemoryRepository[T any, ID comparable] struct {
    data map[ID]T
}

func NewInMemoryRepository[T any, ID comparable]() *InMemoryRepository[T, ID] {
    return &InMemoryRepository[T, ID]{
        data: make(map[ID]T),
    }
}

func (r *InMemoryRepository[T, ID]) FindByID(id ID) (T, error) {
    if entity, ok := r.data[id]; ok {
        return entity, nil
    }
    var zero T
    return zero, fmt.Errorf("entity not found")
}
Enter fullscreen mode Exit fullscreen mode

Type Inference

Go's type inference often allows you to omit explicit type parameters:

// Type inference in action
numbers := []int{1, 2, 3, 4, 5}

// No need to specify [int] - Go infers it
doubled := Map(numbers, func(n int) int { return n * 2 })

// Explicit type parameters when needed
result := Map[int, string](numbers, strconv.Itoa)
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Start Simple

Don't overuse generics. If a simple interface or concrete type would work, prefer that for better readability:

// Prefer this for simple cases
func Process(items []Processor) {
    for _, item := range items {
        item.Process()
    }
}

// Over-generic - avoid
func Process[T Processor](items []T) {
    for _, item := range items {
        item.Process()
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Use Meaningful Constraint Names

Name your constraints clearly to communicate intent:

// Good
type Sortable interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64 | string
}

// Less clear
type T interface {
    int | string
}
Enter fullscreen mode Exit fullscreen mode

3. Document Complex Constraints

When constraints become complex, add documentation:

// Numeric represents types that support arithmetic operations.
// This includes all integer and floating-point types.
type Numeric interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64
}
Enter fullscreen mode Exit fullscreen mode

4. Consider Performance

Generics are compiled to concrete types, so there's no runtime overhead. However, be mindful of code size if you instantiate many type combinations:

// Each combination creates separate code
var intStack = NewStack[int]()
var stringStack = NewStack[string]()
var floatStack = NewStack[float64]()
Enter fullscreen mode Exit fullscreen mode

Real-World Applications

Database Query Builders

Generics are particularly useful when building database query builders or working with ORMs. When working with databases in Go, you might find our comparison of Go ORMs for PostgreSQL helpful for understanding how generics can improve type safety in database operations.

type QueryBuilder[T any] struct {
    table string
    where []string
}

func (qb *QueryBuilder[T]) Where(condition string) *QueryBuilder[T] {
    qb.where = append(qb.where, condition)
    return qb
}

func (qb *QueryBuilder[T]) Find() ([]T, error) {
    / Implementation
    return nil, nil
}
Enter fullscreen mode Exit fullscreen mode

Configuration Management

When building CLI applications or configuration systems, generics can help create type-safe configuration loaders. If you're building command-line tools, our guide on building CLI applications with Cobra & Viper demonstrates how generics can enhance configuration handling.

type ConfigLoader[T any] struct {
    path string
}

func (cl *ConfigLoader[T]) Load() (T, error) {
    var config T
    / Load and unmarshal configuration
    return config, nil
}
Enter fullscreen mode Exit fullscreen mode

Utility Libraries

Generics shine when creating utility libraries that work with various types. For example, when generating reports or working with different data formats, generics can provide type safety. Our article on generating PDF reports in Go shows how generics can be applied to report generation utilities.

Performance-Critical Code

In performance-sensitive applications like serverless functions, generics can help maintain type safety without runtime overhead. When considering language choices for serverless applications, understanding performance characteristics is crucial. Our analysis of AWS Lambda performance across JavaScript, Python, and Golang demonstrates how Go's performance, combined with generics, can be advantageous.

Common Pitfalls

1. Over-constraining Types

Avoid making constraints too restrictive when they don't need to be:

// Too restrictive
func Process[T int | string](items []T) { }

// Better - more flexible
func Process[T comparable](items []T) { }
Enter fullscreen mode Exit fullscreen mode

2. Ignoring Type Inference

Let Go infer types when possible:

// Unnecessary explicit types
result := Max[int](10, 20)

// Better - let Go infer
result := Max(10, 20)
Enter fullscreen mode Exit fullscreen mode

3. Forgetting Zero Values

Remember that generic types have zero values:

func Get[T any](slice []T, index int) (T, bool) {
    if index < 0 || index >= len(slice) {
        var zero T  / Important: return zero value
        return zero, false
    }
    return slice[index], true
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Generics in Go provide a powerful way to write type-safe, reusable code without sacrificing performance or readability. By understanding the syntax, constraints, and common patterns, you can leverage generics to reduce code duplication and improve type safety in your Go applications.

Remember to use generics judiciously—not every situation requires them. When in doubt, prefer simpler solutions like interfaces or concrete types. However, when you need to work with multiple types while maintaining type safety, generics are an excellent tool in your Go toolkit.

As Go continues to evolve, generics are becoming an essential feature for building modern, type-safe applications. Whether you're building data structures, utility libraries, or complex abstractions, generics can help you write cleaner, more maintainable code.

Top comments (0)