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

i finally reached for go generics, and mostly didn't

A look at where Go generics actually earned their keep in a real codebase, and the much larger number of places where an interface or a plain function was still the right answer.

A screen of Go source code in a dim room

Generics have been in Go since 1.18, which is now two years and several point releases ago, so I've run out of excuses to keep calling them new. The honest position I've landed on, after a year of having them available and reaching for them in anger a handful of times, is this: I need them far less often than I expected, and when I do need them, I really need them.

The expectation, coming from years of writing the same []string and []int helpers over and over, was that I'd be deleting reams of duplicated code. That mostly didn't happen. Most of my duplication wasn't actually type-parametric. It was duplication of behaviour that already had a perfectly good home in an interface. A function that takes an io.Reader doesn't need a type parameter; it needs the reader. I'd been conflating "I keep writing similar code" with "I keep writing the same code over different types", and those are not the same problem.

where it genuinely helped

The place generics paid off immediately was a small kit of collection helpers. Map, Filter, Keys, and friends. Before 1.18 I either wrote them per-type or reached for reflection, and reflection in a hot path is a quiet apology you make to your future profiler.

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

func Keys[K comparable, V any](m map[K]V) []K {
	out := make([]K, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	return out
}

That's it. It's not clever, and that's the point. It's correct for every element type, the compiler checks it, and there's no interface{} round trip to box and unbox every value. The comparable constraint on Keys is doing real work: it stops me handing it a map I couldn't have made in the first place.

The other clear win was a typed cache. A Cache[K comparable, V any] with the usual get, set and expiry, written once, used for sessions in one place and rendered-template fragments in another. Previously that was either two near-identical caches or one cache of interface{} with a type assertion at every call site, and type assertions are just runtime errors waiting for a Tuesday.

Close-up of a code editor showing a generic function signature

where i talked myself out of them

For everything shaped like "do a thing to a stream of bytes", or "talk to a store", an interface was still cleaner and read better at the call site. I drafted a generic Repository[T] early on, felt clever for an afternoon, and then quietly reverted it. The constraints needed to make it useful (an ID, a way to marshal, a table name) ended up looking exactly like an interface anyway, only with more angle brackets and worse error messages. When you find yourself writing a constraint that lists the methods a type must have, you have rediscovered interfaces, and you should let them win.

The error messages are worth a separate grumble. When a type doesn't satisfy a constraint, the message is improving release by release but still reads like the compiler is cross with you specifically. With interfaces, an unsatisfied method set tells you the missing method by name. With constraints you sometimes get a wall of type sets to squint at.

So: do you even need them? Probably less than the hype suggested, and exactly as much as your duplicated container code suggests. My rule now is boring. Reach for an interface first, because behaviour is usually what varies. Reach for a generic only when the type itself is the thing that's repeating and an interface would force a boxing or an assertion you'd rather not pay for. Map, Filter, a typed cache, a typed set: yes. Everything else: probably still an interface, and that's fine.