I lost an afternoon last month to a function called mapStrings. Then I found mapInts sitting two packages over. Then mapUsers, which was the same function a third time, with different variable names and an off-by-one in the capacity hint. All three were mine. I have no memory of writing any of them.
Go has shipped generics since 1.18, which means I had type parameters available the entire time I was copy-pasting that function around my own codebase. So this is partly an explainer and partly a confession.
Short version if you’re in a hurry: type parameters earn their keep in containers and small utility functions. They usually don’t earn it in business logic. The line between those two is fuzzier than I expected, and I’ve now crossed it in both directions and had to walk back.
The helper I wrote four times
Here’s 2021 me, pre-generics, doing the obvious thing:
func MapStrings(in []string, f func(string) string) []string {
out := make([]string, 0, len(in))
for _, v := range in {
out = append(out, f(v))
}
return out
}
func MapInts(in []int, f func(int) int) []int {
out := make([]int, 0, len(in))
for _, v := range in {
out = append(out, f(v))
}
return out
}
I did try the interface{} route once, on the theory that one function beats four. It was worse in every way that mattered. Every call site needed a type assertion, the compiler stopped helping me, and a mistake that used to be a build failure turned into a panic in staging at an hour I’d rather not discuss.
func Map(in []interface{}, f func(interface{}) interface{}) []interface{} {
// one function, and now every caller pays for it in assertions
}
The generic version is what I should have written:
func Map[T, U any](in []T, f func(T) U) []U {
out := make([]U, 0, len(in))
for _, v := range in {
out = append(out, f(v))
}
return out
}
names := Map(users, func(u User) string { return u.Name })
Note the call site. You rarely write Map[User, string] by hand because inference handles it from the arguments. That inference is the reason this feature is pleasant rather than noisy, and it’s the thing that makes generic Go read like normal Go. The generics tutorial on go.dev covers the same shape if you want the language team’s framing.
Constraints are where the actual thinking happens
any and comparable cover most of what I write. comparable is the one people trip on: it means the type supports ==, so slices, maps and functions are out. If you’re building a generic set or using a type parameter as a map key, comparable is your constraint.
Union constraints are where it gets more interesting:
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
That tilde matters, and I got it wrong for a solid week. Plain int in a constraint means exactly int. ~int means any type whose underlying type is int, which includes your type UserID int. Without the tilde, Sum([]UserID{...}) doesn’t compile, and the error points at the constraint rather than at the missing squiggle, so you go looking in the wrong place. The spec section on type parameter declarations is dry reading but it’s the only place that states this precisely.
Constraints can also require methods, which is how you write something generic over anything with a String() on it. In practice I reach for that less than I expected. If all I need is a method, a plain interface parameter already worked fine and doesn’t need type parameters at all.
Inference stops at the arguments
This one cost me an evening. Inference works from what you pass in, not from what you assign the result to.
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
}
ids := Keys(usersByID) // fine, K and V come from the argument
But a function shaped like a constructor has nothing to infer from:
func Zero[T any]() T {
var z T
return z
}
n := Zero[int]() // explicit instantiation required
Once that clicked, most of my confusion about “why does this one need brackets” went away. If the type parameter doesn’t appear in a parameter, you’re typing it out.
Where I backed generics out
I built a Repository[T] once. Generic Get, List, Save, the lot.
It looked clean for about three weeks. Then the user repo needed a lookup by email, the invoice repo needed a date range filter with pagination, and the audit repo needed to never delete anything. I ended up with a generic base plus a pile of per-type methods hanging off it, which came to more code than the three concrete structs I started with, and with an extra layer of indirection to read through.
Generics compress code that was genuinely identical and only differed by type. Map was identical. My repositories only looked identical from a distance, and the distance was doing all the work.
There’s a hard constraint that pushes the same direction: you can’t add type parameters to methods, only to functions and types. If your design wants func (r *Repo) Find[T any](...), Go says no. Better to find that out before you draw the diagram.
The standard library quietly deleted half my utility package
This is the part I underused for far too long. slices and maps are in the standard library now, both built on type parameters, and between them they cover a lot of what lives in everyone’s internal/util.
Before:
sort.Slice(users, func(i, j int) bool {
return users[i].Age < users[j].Age
})
found := false
for _, u := range users {
if u.ID == target {
found = true
break
}
}
After:
slices.SortFunc(users, func(a, b User) int {
return cmp.Compare(a.Age, b.Age)
})
found := slices.ContainsFunc(users, func(u User) bool { return u.ID == target })
slices.SortFunc wants a comparison that returns an int, not a bool, which caught me out the first time I swapped one in. Read the slices package docs before you write anything that loops over a slice looking for something. There’s a good chance it already exists and has been fuzzed harder than your version.
Generic iterators are the other half of this story and they changed how I write pipelines enough that I gave them their own writeup on Go 1.23 iterators.
The rough edges I’ve actually hit
Cold build times went up on the packages where I lean hardest on type parameters. Not dramatically, but enough to notice on a fresh checkout.
Constraint error messages are still rougher than the rest of Go’s diagnostics. When inference fails you get told a constraint isn’t satisfied, and working out which of your type parameters caused it is on you.
And signatures get heavy fast. func Reduce[T, U any](in []T, init U, f func(U, T) U) U is readable on its own. Three of those stacked in one file is a wall.
What I’d actually do this week
Grep your codebase for functions whose names differ only by a type: mapStrings and mapInts, findUser and findInvoice. If the bodies are the same once you squint past the types, that’s exactly the pile generics were built for. Everything else, leave alone.
Then read the slices and maps docs properly and delete whatever they already do for you. I got about 120 lines back that way, which is the kind of quiet cleanup that never makes a changelog but makes the next person’s week easier. It’s the sort of thing I end up doing on most backends I take on, and there’s more of that in my work. Concurrency helpers are the other place worth a sweep, which I went into separately in my notes on errgroup and context patterns.