Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 061 / 100

Go generics explained: a practical guide for developers

Type parameters since Go 1.18.

Expona Admin7 min read

Introduction

When Go 1.18 landed in March 2022, it brought a feature the language had resisted for over a decade: generics. For teams building production APIs, this shift changes how we write reusable utility functions, especially around collections and type-safe wrappers. Go generics explained: a practical guide for developers walks through type parameters, constraints, and when to actually use them—because generics are powerful, but they're not a hammer for every nail.

We've shipped APIs across e-commerce, trading platforms, and SaaS products long before generics existed. Now that they're here, we can eliminate a lot of boilerplate and type assertions without losing clarity. Let's see how.

Understanding type parameters and the Map function

The classic entry point to generics is a Map function. Pre-generics, you'd either write separate functions for each type or accept interface{} and do runtime type assertions. Neither is ideal in production code.

With type parameters, you declare placeholders for types in square brackets:

go
func Map[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

T and U are type parameters. any is a constraint—it means these types can be anything. When you call Map, the compiler infers the concrete types from your arguments:

go
nums := []int{1, 2, 3}
doubled := Map(nums, func(x int) int {
    return x * 2
})
// doubled is []int{2, 4, 6}

strings := []string{"a", "b"}
lengths := Map(strings, func(s string) int {
    return len(s)
})
// lengths is []int{1, 1}

The compiler generates specialized code for each type pair you use—no reflection, no interface{}, no runtime overhead. This is compile-time polymorphism.

Constraints define allowed types

Not every function should accept any. Constraints restrict which types a type parameter can be. A constraint is an interface—it defines a contract that types must satisfy.

The standard library ships with useful constraints in the constraints package:

go
import "golang.org/x/exp/constraints"

func Sum[T constraints.Integer | constraints.Float](s []T) T {
    var total T
    for _, v := range s {
        total += v
    }
    return total
}

sum1 := Sum([]int{1, 2, 3})           // 6
sum2 := Sum([]float64{1.5, 2.5})      // 4.0

Here, constraints.Integer | constraints.Float uses union syntax. The function only accepts numeric types. If you pass a string, the compiler rejects it immediately.

You can also write custom constraints:

go
type Comparable interface {
    Compare(other Comparable) int
}

func Min[T Comparable](a, b T) T {
    if a.Compare(b) < 0 {
        return a
    }
    return b
}

In production, constraints let you be explicit about what your generic code needs. A teammate reading your function signature knows exactly what operations are safe on type T.

Great for collections and utilities

Generics shine when you're writing collection helpers and utility packages—exactly the code that tends to live in shared libraries across your codebase.

Before generics, a generic slice filter often looked like this:

go
// Old approach—no type safety
func FilterAny(s []interface{}, predicate func(interface{}) bool) []interface{} {
    var result []interface{}
    for _, v := range s {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

With generics, you get the same flexibility without type assertions:

go
func Filter[T any](s []T, predicate func(T) bool) []T {
    var result []T
    for _, v := range s {
        if predicate(v) {
            result = append(result, v)
        }
    }
    return result
}

orders := []Order{...}
largeOrders := Filter(orders, func(o Order) bool {
    return o.Total > 1000
})

The caller never thinks about casting. The compiler knows exactly what you're filtering. This pattern scales across a codebase—pagination helpers, batching utilities, and data transformation functions all benefit.

Another solid use case: cache or pool wrappers:

go
type Cache[K comparable, V any] struct {
    data map[K]V
    mu   sync.RWMutex
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.data[key]
    return val, ok
}

func (c *Cache[K, V]) Set(key K, val V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = val
}

You can instantiate Cache[string, User] or Cache[int64, Order] safely, and the type system ensures you don't mix them up. No reflection, no runtime overhead.

Do not replace interfaces everywhere

This is the critical bit. We see teams add generics to every function and suddenly their codebase becomes harder to read. Generics and interfaces solve different problems.

Use interfaces when:

  • You have multiple implementations of the same behavior (a database driver might be PostgreSQL or MySQL).
  • You want runtime polymorphism and loose coupling between components.
  • A function's contract is about behavior, not the underlying type.

Use generics when:

  • You're writing a reusable collection or container that works the same way for any type.
  • You need compile-time type safety to prevent bugs.
  • You want to avoid boilerplate without sacrificing performance or clarity.

Bad example—replacing interfaces with generics:

go
// Don't do this
type Reader[T any] interface {
    Read() (T, error)
}

// Multiple implementations? Use interfaces instead.
type PaymentReader interface {
    Read(ctx context.Context) (*Payment, error)
}

type FilePaymentReader struct { ... }
type APIPaymentReader struct { ... }

Good example—generics for a utility:

go
// This makes sense
func Batch[T any](items []T, size int) [][]T {
    var batches [][]T
    for i := 0; i < len(items); i += size {
        end := i + size
        if end > len(items) {
            end = len(items)
        }
        batches = append(batches, items[i:end])
    }
    return batches
}

In production systems, this restraint matters. Interfaces are the language's strength for abstraction. Generics complement them by handling the nuts-and-bolts plumbing.

Common mistakes

Over-constraining too early. You don't need to write fancy constraints on day one. Start with any and tighten constraints only if you need specific operations. Adding constraints later is easy; removing them is friction.

Treating generics as syntactic sugar. Every type parameter the compiler generates is actual code—monomorphization. If you have 20 different instantiations of the same generic type, you have 20 copies. That's fine for small utilities, but watch your binary size on embedded or resource-constrained deployments.

Mixing generics and nil values. In Go, the zero value of a type parameter is always meaningful. You can't assume it's nil. If a generic function needs to signal "no value," consider returning (T, bool) or using pointers, not nil comparisons.

go
// Risky—doesn't work for all types
func First[T any](s []T) T {
    if len(s) == 0 {
        return nil // Compile error: can't assign nil to T
    }
    return s[0]
}

// Better
func First[T any](s []T) (T, bool) {
    if len(s) == 0 {
        var zero T
        return zero, false
    }
    return s[0], true
}

Key takeaways

  • Type parameters let you write compile-time polymorphic functions without interface{} or runtime overhead.
  • Constraints enforce contracts on types—use them to be explicit about what operations are allowed.
  • Generics excel for collection utilities, helpers, and containers; they reduce boilerplate and improve type safety.
  • Interfaces remain the right tool for abstraction and runtime polymorphism; don't replace them wholesale with generics.
  • Start simple with any constraints and tighten only when you need specific operations; watch binary size in production builds.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles