Ramblings of an aging IT geek
← Ramblings of an aging IT geek
golang

generics in go, do i even need them

Three years after generics landed in Go 1.18, an honest account of where they actually earned their keep in my code and where they were a mistake.

Go source code on a screen

Generics landed in Go 1.18 back in early 2022, and I greeted them the way I greet most new language features: with a mixture of relief and suspicion. Relief because I'd written interface{} and a type assertion more times than I care to admit. Suspicion because every language that adds generics seems to spawn a small industry of people using them to build cathedrals nobody asked for.

Three years on, with 1.24 out, I've a reasonable amount of generic code in production and a reasonable amount that I wrote, regretted, and tore back out. So here's the honest answer to "do I even need them": rarely, but when you do, the alternative was genuinely worse.

where they earned their keep

The clearest win, and it's not glamorous, is small container and collection helpers. The functions you've written a hundred times against []int and []string and copy-pasted between them, slightly wrong each time.

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

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

Nothing clever. But before generics you either wrote these per type, or you went via []interface{} and paid for it in boxing, allocation and lost type safety at the call site. Now they're type-safe, allocation-free except for the result, and the compiler shouts at you if you misuse them. The standard library agreed, eventually: slices and maps arrived in 1.21 and I've happily deleted most of my hand-rolled versions in favour of slices.Contains, slices.SortFunc, maps.Keys and friends.

The second real win is constraints on numeric code. A Sum or a Max that works across every numeric type without losing precision or writing the same loop eight times:

type Number interface {
    ~int | ~int64 | ~float64
}

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

The ~ matters more than it looks. It means "any type whose underlying type is this", so your type Celsius float64 still satisfies the constraint. That's the bit that made me trust generics: the type system is actually thinking about the underlying representation, not just the name.

A diagram of type constraints and underlying types

The third place, and this is the one I'd defend hardest, is type-safe wrappers around things that were previously stringly-typed or interface{}-shaped. Caches, result types, optional values. A Cache[K comparable, V any] reads better at every call site than a map[string]interface{} with a comment explaining what's really in it. The call site is where the value lands, because that's where the next person to read the code has to reconstruct what type they're dealing with.

where I regretted them

Now the other half, because it's the more useful half.

I wrote a generic "repository" abstraction. Repository[T any] with Get, List, Save, the lot, the idea being every entity would get one for free. It was lovely in the abstract and miserable in practice. The moment any entity needed a query that didn't fit the generic shape, and they all did within a fortnight, I was either bolting type-specific methods onto a supposedly generic type or threading yet another type parameter through to express the query. I ended up with a signature that had four type parameters and a constraint interface longer than the function it constrained. I deleted the whole thing and wrote plain per-entity repositories. They're more lines of code and far less clever, and I've not thought about them since, which is the highest praise I can give code.

A tangle of nested type parameters in an editor

The pattern in the failure is clear in hindsight: generics are wonderful for code where the type genuinely doesn't matter to the logic. Map doesn't care what T is; it never inspects it. The repository cared enormously what T was, because different entities have different queries, different indexes, different invariants. The moment your "generic" code wants to know things about its type parameter, you're fighting the tool, and the tool will win.

The other regret is more subtle: error messages and readability. A failed type inference on a four-parameter generic function produces an error that takes real effort to parse, and the function signature itself becomes a wall of [K comparable, V any, Q Queryable[K], R Result[V]] that a reader has to decode before they can even start on the body. Every type parameter is a small tax on everyone who reads the code later. One or two, paid gladly. Four, and you're in debt.

the rule I've settled on

I now ask one question before reaching for a type parameter: does the logic actually ignore the type? If the function would work identically regardless of what's flowing through it (a map, a filter, a cache, a channel fan-out), then a type parameter is the right call and the result is genuinely better than what came before. If the function needs to know anything specific about the type, branch on it, validate it, query it, then I write the concrete version and accept the duplication.

So, do I even need them? Less often than the people writing generic libraries would have you believe, and more often than my 1.17 self would have guessed. They're a precision tool. Used on the handful of places that are truly type-agnostic, they remove a whole category of interface{} noise and make the code safer and faster at once. Used as a general architecture strategy, they turn into the kind of abstraction you spend a quiet Friday afternoon deleting, slightly embarrassed, and feeling much better afterwards.