Go Generics Explained: Write Flexible, Reusable Code and Build Shared Libraries

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

The Problem: Copy-Paste Hell in a Statically Typed Language

Picture a typical Go codebase. There’s a function that finds the minimum value in a slice of integers. Then someone needs the same thing for float64. Then int64. Suddenly you have MinInt, MinFloat64, MinInt64 — three functions doing exactly the same thing with different types.

Before Go 1.18, the workarounds were ugly. You either duplicated code, or reached for interface{} (the old any), which meant losing all type safety and scattering type assertions throughout the codebase. Both paths made things harder to maintain and easier to break.

The underlying issue: Go’s type system, while excellent for clarity and safety, had no way to express “this function works for any numeric type.” Generics, released in March 2022 with Go 1.18, finally fixed that.

Core Concepts You Need to Know

Type Parameters

Generics in Go use type parameters — placeholders declared in square brackets that get filled with real types at call time.

func Min[T int | float64](a, b T) T {
    if a < b {
        return a
    }
    return b
}

The [T int | float64] part says: “T can be either int or float64.” Call Min(3, 5) and Go infers that T is int. No explicit type argument needed.

Constraints: The Heart of Go Generics

A constraint defines what types a type parameter may be. The simplest is any — no restriction, equivalent to interface{}. More useful constraints are built from interfaces.

Go 1.21 added the cmp and slices standard library packages with ready-made constraints. For custom ones, define an interface:

type Number interface {
    int | int8 | int16 | int32 | int64 |
        float32 | float64
}

func Sum[T Number](values []T) T {
    var total T
    for _, v := range values {
        total += v
    }
    return total
}

Now Sum works for any numeric type. Pass []int, []float64, or []int32 — it just works.

The ~ Tilde Operator: Covering Custom Types

Here’s something that trips people up. Given a custom type like type Celsius float64, the constraint float64 alone won’t match it. You need the tilde:

type Temperature interface {
    ~float32 | ~float64
}

~float64 means “any type whose underlying type is float64.” That covers both float64 itself and any named type built on top of it — like Celsius.

Multiple Type Parameters

Functions can declare more than one type parameter. This is useful when building map-like transformations:

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
}

Transform a slice of any type into a slice of any other type. No interface casting, no reflection.

Hands-On Practice: Building a Reusable Utility Library

Internal utility packages are where generics pay off most. Instead of each team reimplementing the same slice helpers or collection types, you write them once and share. Here’s a small but practical library to illustrate.

A Generic Filter Function

package sliceutil

// Filter returns a new slice containing only elements for which
// the predicate function returns true.
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
}

Usage:

evens := sliceutil.Filter([]int{1, 2, 3, 4, 5, 6}, func(n int) bool {
    return n%2 == 0
})
// evens = [2, 4, 6]

long := sliceutil.Filter([]string{"go", "rust", "python", "c"}, func(s string) bool {
    return len(s) > 2
})
// long = ["rust", "python"]

One function handles both cases. No type assertions. No runtime panics from wrong casts.

A Generic Set Type

Before generics, most teams either used map[string]struct{} directly (limited to strings) or wrote a separate Set implementation per type (tedious and copy-pastey). With generics, write it once:

package collections

type Set[T comparable] struct {
    items map[T]struct{}
}

func NewSet[T comparable]() *Set[T] {
    return &Set[T]{items: make(map[T]struct{})}
}

func (s *Set[T]) Add(item T) {
    s.items[item] = struct{}{}
}

func (s *Set[T]) Contains(item T) bool {
    _, ok := s.items[item]
    return ok
}

func (s *Set[T]) Remove(item T) {
    delete(s.items, item)
}

func (s *Set[T]) Size() int {
    return len(s.items)
}

// Union returns a new set containing all elements from both sets.
func Union[T comparable](a, b *Set[T]) *Set[T] {
    result := NewSet[T]()
    for k := range a.items {
        result.Add(k)
    }
    for k := range b.items {
        result.Add(k)
    }
    return result
}

The constraint comparable is built into Go — it means the type supports == and !=, which is required for map keys. Works with any comparable type:

intSet := collections.NewSet[int]()
intSet.Add(1)
intSet.Add(2)
intSet.Add(1)
fmt.Println(intSet.Size()) // 2

strSet := collections.NewSet[string]()
strSet.Add("go")
strSet.Add("rust")
fmt.Println(strSet.Contains("go")) // true

A Cache with Expiry

Take it further with a generic in-memory cache that supports TTL:

package cache

import (
    "sync"
    "time"
)

type entry[V any] struct {
    value     V
    expiresAt time.Time
}

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

func New[K comparable, V any]() *Cache[K, V] {
    return &Cache[K, V]{items: make(map[K]entry[V])}
}

func (c *Cache[K, V]) Set(key K, value V, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = entry[V]{value: value, expiresAt: time.Now().Add(ttl)}
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    e, ok := c.items[key]
    if !ok || time.Now().After(e.expiresAt) {
        var zero V
        return zero, false
    }
    return e.value, true
}

Usage is fully typed — no casting, no interface{} leaking out:

// Cache mapping string keys to User structs
userCache := cache.New[string, User]()
userCache.Set("u:123", user, 5*time.Minute)

if u, ok := userCache.Get("u:123"); ok {
    fmt.Println(u.Name)
}

When Not to Use Generics

Knowing when to skip them matters as much as knowing how to use them. Avoid generics when:

  • The function only ever works with one concrete type — just use that type directly.
  • You’re adding a type parameter solely to avoid writing a second function — sometimes two specific functions are clearer than one generic one.
  • The logic inside differs significantly per type — that’s not a generics problem, that’s polymorphism via interfaces.

A useful heuristic: if you can express the behavior with a regular interface and method dispatch, do that. Reach for generics when the structure of the algorithm is the same and only the type varies.

Putting It Together

Once you’ve migrated a handful of copy-pasted utility functions to generic versions, the difference is hard to ignore. Less duplication. No type assertions hiding potential runtime panics. Shared libraries that teams actually reuse instead of quietly copying into their own packages.

Start small. Rewrite your most-duplicated helpers. Build a typed collection or two. Once constraint design clicks, you’ll start spotting natural opportunities throughout the codebase.

The Go team deliberately kept generics conservative — simpler than C++ templates, less magical than Rust’s trait system. That means less power in extreme edge cases, but also means a new team member can read generic code without a steep learning curve. For the vast majority of production Go code, that’s exactly the right trade-off.

Share: