Skip to content

Go Generics, Four Years In: What I Actually Reach For Now

Go Generics, Four Years In: What I Actually Reach For Now

I was halfway through writing a 200-line map[string]interface{} cache last March when I caught myself. Generics shipped in Go 1.18 back in 2022. It’s 2026. Why was I still doing this?

The truth: habit. The codebase predates 1.18, the linter wasn’t going to yell at me, and interface{} (or any, same thing now) feels like home if you’ve written enough Go. But the half-written cache had three type assertions in it already and I hadn’t even gotten to the eviction logic. So I rewrote it as a generic, deleted about forty lines, and moved on.

Four years in, that’s roughly how I think about generics in Go now. They’re useful for a narrow class of problems. The wins are smaller than the launch posts implied, and the failure modes are subtle. Here is what I actually reach for and what I learned to leave alone.

The wins that justified my early bets

The clearest wins are the ones the standard library eventually caught up to: slices and maps. Before 1.21, I had a pkg/sliceutil directory in nearly every Go service I wrote. Different repo, same six functions: Contains, Filter, Map, Keys, Values, Equal. After slices.Contains and maps.Keys landed in the standard library, I deleted that directory and never missed it.

Before:

func ContainsString(s []string, v string) bool {
    for _, x := range s {
        if x == v {
            return true
        }
    }
    return false
}

func ContainsInt(s []int, v int) bool {
    for _, x := range s {
        if x == v {
            return true
        }
    }
    return false
}

After:

import "slices"

slices.Contains([]string{"a","b","c"}, "b") // true
slices.Contains([]int{1,2,3}, 4)            // false

That’s it. One function, every comparable type. Multiply that across Filter, IndexFunc, SortFunc, and you delete a directory.

The other clear win is container types you can actually trust. I write a generic cache.LRU[K comparable, V any] once, and the call site is c.Get(userID) returning (*User, bool) with no type assertion. The thing I was reaching for interface{} to do, generics now do without the runtime cost or the panic-on-bad-cast.

Type constraints I actually write (and the ones I stopped writing)

The constraint zoo is where I spent a couple of weeks being too clever in 2022. I had a custom Number interface that unioned int, int8, int16, int32, int64, float32, float64, plus unsigned variants. I wrote a generic Sum, Average, Median. Then I never used Median again.

Today my constraint catalog is short:

// What I actually use, almost always:
//   - comparable (built in, for map keys and == checks)
//   - any        (built in, for "I don't care about the type")
//
// And occasionally:
import "cmp"
// cmp.Ordered from 1.21 gives me <, >, <=, >= for free
func Max[T cmp.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

Most of my generic functions take comparable or any and stop there. If I find myself writing a three-line custom constraint, that’s usually the signal I should not be writing a generic at all and should just write the concrete function. Generics in Go are not Haskell. The compiler error messages when constraints get clever are bad, and the stack traces are worse.

The one custom constraint I keep around is for repository-pattern stuff:

type Entity interface {
    GetID() int64
}

type Repository[T Entity] struct {
    db *sql.DB
}

func (r *Repository[T]) FindByID(ctx context.Context, id int64) (T, error) {
    var zero T
    // query, scan, return
    return zero, nil
}

One method on the constraint. If I’m tempted to add a second, I’m usually about to regret it.

When a generic ate my afternoon (and what I do instead)

Here is the specific pattern I keep almost-falling-into and then backing away from: “make this generic so it works for both X and Y.”

Real example. I had a function ProcessUserEvents and a function ProcessOrderEvents. They were 70% the same code. I tried to extract them into ProcessEvents[T any] with a callback. The callback signature got progressively uglier. By the time I had it compiling, the call sites read worse than the original copy-paste, and the test setup was harder to follow because the mocks now needed generic parameters too.

I reverted. Two concrete functions, twenty lines of duplication. The duplication was honest. Nobody got confused.

The lesson I keep relearning: generics are good when the behavior is identical across types and only the type changes. They are bad when the behavior is similar but not identical and you’re trying to paper over the differences with callbacks. I’ve made the same mistake with generic React hooks in TypeScript, which I wrote about in a separate post on TypeScript generics, and the fix in both languages turns out to be the same: stop, write two functions.

If you read the original Go team rationale in the intro-generics blog post, they call this out explicitly. Generics are for code that operates on values of any type. Not for code that operates on two specific types and you’d rather not write it twice. Two functions is fine. Two functions is often better.

Generics plus iterators in Go 1.23: where it finally clicked

The thing that made me actually enjoy generics in Go was Go 1.23’s range-over-function iterators. Before iterators, generic functions that returned collections felt clunky. You’d return []T and the caller would range over it, and any laziness or composition was on you to build with channels.

With iter.Seq[T] and iter.Seq2[K,V], generic functions compose. Example: I have a paginated database query and I want filtered, deduplicated results.

Before (pre-1.23):

// Allocate a full slice, filter, then dedupe.
// Memory bloats for large result sets.
rows, _ := db.QueryAll(ctx, q)
filtered := []User{}
for _, u := range rows {
    if u.Active {
        filtered = append(filtered, u)
    }
}
seen := map[int64]bool{}
unique := []User{}
for _, u := range filtered {
    if !seen[u.ID] {
        unique = append(unique, u)
        seen[u.ID] = true
    }
}

After (1.23+):

func Filter[T any](seq iter.Seq[T], pred func(T) bool) iter.Seq[T] {
    return func(yield func(T) bool) {
        for v := range seq {
            if pred(v) && !yield(v) {
                return
            }
        }
    }
}

users := Filter(db.IterUsers(ctx, q), func(u User) bool { return u.Active })
for u := range users {
    // process one at a time, no intermediate slice
}

iter.Seq[T] is generic, my Filter is generic, and the result composes without allocating an intermediate slice. I dug into the iterator side of this in an earlier post on Go’s range-over-func iterators. The short version: iterators are the feature that made the generics investment finally pay off for me.

What I run before I add a new type parameter

I have a small checklist I work through before reaching for a [T]. Nothing formal, just three questions I ask myself when I’m about to type the bracket.

First, am I going to call this for at least three concrete types? If the answer is “two, maybe,” I write two functions. If it’s “one but I might use more later,” I write one function and revisit it when the third caller actually shows up.

Second, does the type parameter actually flow through the function, or am I just using it to delay a decision? If the function takes T, accepts a func(T) X callback, and returns []X, the T is doing real work. If the function takes T and converts it to any two lines in, I’m cosplaying.

Third, will the call site read better with or without the type parameter? slices.Contains(names, "alice") is great. repo.Save[*User](u) where the type is already obvious from u is friction. Go usually infers, but when you have to write the parameter explicitly at the call site, that’s a hint the generic might not be earning its keep.

One thing to try this week

Pull up a Go service you wrote before 1.21 and grep for interface{} and any. Pick one occurrence. Ask whether the runtime type assertion is paying for itself, or whether a small refactor to a generic function would save you a class of bugs. Don’t refactor everything. Pick one. See if the diff makes the file shorter and the tests less surprising. If yes, do another one next week. If no, you’ve answered the question for that pattern.

That is how I built up my actual generics usage over four years. Not by reading the spec. By picking one piece of code at a time and asking whether the abstraction was worth its weight. If you want to see what else I work on along these lines, the work section of my site has more of it.