Skip to content

Go generics: the three patterns I actually use

Go generics: the three patterns I actually use

Confession: when Go 1.18 shipped generics in early 2022, I read the release notes, wrote a toy Map[T, U] helper, and then went right back to interface{} for another year. I wasn’t being stubborn. I just couldn’t point at anything in my day job where generics would make the code clearly better.

Four years later my position is different, but not because I suddenly fell in love with type parameters. I found three specific patterns where Go generics actually pay their rent, and about five patterns where they quietly make my code worse. This post is the honest list.

If you’ve been reading Go posts where someone wraps every helper in a [T any] and calls it progress, this isn’t one of those. I’ll show the patterns I actually ship, with the old interface{} version next to the new one so you can see what changes and what doesn’t.

Pattern 1: typed containers, the feature’s clearest win

Before 1.18, if I wanted a set in Go, I wrote a map[string]struct{} and moved on. If I needed one for ints too, I either duplicated the code or reached for map[interface{}]struct{} and learned to live with type assertions. Neither was great.

Here’s the old version:

// Pre-generics set, specialised for strings.
type StringSet map[string]struct{}

func (s StringSet) Add(v string)      { s[v] = struct{}{} }
func (s StringSet) Has(v string) bool { _, ok := s[v]; return ok }

And the generic one I actually use now:

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

func NewSet[T comparable](xs ...T) Set[T] {
    s := make(Set[T], len(xs))
    for _, x := range xs {
        s[x] = struct{}{}
    }
    return s
}

func (s Set[T]) Add(v T)      { s[v] = struct{}{} }
func (s Set[T]) Has(v T) bool { _, ok := s[v]; return ok }

Three things I care about here. comparable is the right constraint for a map key and the compiler enforces it. There’s zero boxing or type assertion at the call site. And I write this type once and it works for my string IDs, int64 primary keys, and custom struct keys. The readability cost is tiny because the set pattern is already familiar.

I use the same shape for typed priority queues and bounded ring buffers. Containers are the clearest win generics gave Go, and the standard library agrees: the slices and maps packages added in 1.21 are exactly this pattern at scale.

Pattern 2: thin data helpers, not clever ones

My second pattern sits one level up from containers: tiny generic helpers over slices that I keep reaching for.

Before:

// I wrote something like this in every service.
func FirstUser(users []User, match func(User) bool) (User, bool) {
    for _, u := range users {
        if match(u) {
            return u, true
        }
    }
    return User{}, false
}

After:

func First[T any](xs []T, match func(T) bool) (T, bool) {
    for _, x := range xs {
        if match(x) {
            return x, true
        }
    }
    var zero T
    return zero, false
}

This looks small, but I’d written maybe eight versions of it across services before 1.18. The generic First lives in one place, I import it from our internal xslices package, and every team uses the same signature. The trick is keeping these helpers thin: single purpose, no nested callbacks, no early-return cleverness. Once a helper grows past fifteen lines, I stop and ask whether it should be a method on a real type instead.

For longer operations, the standard library’s slices.IndexFunc and its neighbours cover most of what I used to write by hand. When Go 1.23 added range-over-func iterators, some of my homegrown helpers became one-liners, which was a genuinely satisfying diff.

Pattern 3: functional-ish adapters at service boundaries

This is the one that took me longest to trust.

A typical service I work on fetches rows from Postgres, transforms them, and hands them to an HTTP handler. The transformation step used to be a tower of nested for-loops. It now looks like this:

type Mapper[In, Out any] func(In) Out

func MapSlice[In, Out any](xs []In, f Mapper[In, Out]) []Out {
    out := make([]Out, len(xs))
    for i, x := range xs {
        out[i] = f(x)
    }
    return out
}

Used:

dtos := MapSlice(rows, func(r Row) RowDTO {
    return RowDTO{ID: r.ID, Name: r.Name, Price: r.PriceCents / 100}
})

I used to feel guilty about this, because Go’s orthodoxy is “just write the for-loop”. The for-loop is fine. But when the transformer function is pure and obvious, MapSlice reads better than out := make([]RowDTO, 0, len(rows)) followed by a loop, and it nudges me to write small testable Mapper functions instead of inlining mapping logic inside handlers.

One rule I keep: no more than two type parameters per helper, and no constraints beyond any, comparable, or the built-in ordered types. The moment I want a method set in the constraint, I reach for a plain interface instead. Generics aren’t the only tool in Go and they shouldn’t pretend to be.

The pattern I quietly stopped using

Here’s the one I got wrong early: wrapping a helper in generics just in case I needed it later.

// Don't. This helper only ever takes user IDs.
func LoadRecord[T any](ctx context.Context, id T) (*Record, error) {
    row := db.QueryRow(ctx, "select * from records where id = $1", id)
    // ...
}

If every call site passes a string user ID, the [T any] isn’t earning its keep. It adds a tiny mental tax on every reader (“what types does this accept? do I need a constraint?”) without buying anything. I’ve since ripped this kind of thing out of three services and the code got shorter and clearer.

The fix is simple: keep the helper concrete until a second caller with a different type forces your hand. People quote the rule of three for refactoring; for generics I’d lower it to two. One specialised function is not a problem. Two is a gentle nudge to think about whether a type parameter fits.

Constraints I wish I’d learned on day one

Generics in Go aren’t only [T any]. Three constraints pulled their weight for me:

  • comparable for anything that goes in a map key or gets compared with ==. The Set example above is the canonical use.
  • cmp.Ordered (added in 1.21) for anything you can use with <, >, <=, >=. Great for typed Min, Max, and simple sort helpers.
  • Interface constraints with type sets, when you actually need a method. For example:
type Stringer interface{ String() string }

func JoinStringers[T Stringer](xs []T, sep string) string {
    parts := make([]string, len(xs))
    for i, x := range xs {
        parts[i] = x.String()
    }
    return strings.Join(parts, sep)
}

The Go team’s intro to generics and the longer design doc spend a lot of pages on type sets, and it took me two reads before the syntax clicked. If you’ve only ever used [T any], spending an hour on type-set constraints is the best generic Go investment you can make this month.

When I still reach for plain interfaces

Not everything wants to be generic. I still use non-generic interfaces when:

  • The call site holds a slice of differently-typed values that share behaviour, like []Notifier where each Notifier is a different concrete type.
  • I’m writing a plugin boundary and I want runtime polymorphism, so adding a new implementation doesn’t require the caller to recompile against a new type.
  • The method set is genuinely all I care about and the concrete type is none of my business.

Generics and interfaces don’t compete. They solve different problems. Use generics when the caller picks the type. Use interfaces when the callee has to handle one of many types at runtime.

If you want to see how these patterns shift when you change languages, I wrote up Rust vs Go in 2026 recently, and Rust’s trait/generic model makes very different tradeoffs for similar problems. And if you’re staring at a Go service that’s grown a thicket of [T any] wrappers, my work is public and the untangling-Go-services part is a thing I actually enjoy.

What to try this week

Open your largest internal utility package. Find one function you’ve copy-pasted with different types (the odds are high). Pick the most common version, add a single type parameter, and delete the duplicates. Run your tests. If they pass, ship it. If they don’t, the failure will tell you which constraint the function actually needed.

Start there. Don’t rewrite your service. Don’t rewrite your team’s service either. Just replace one set of duplicated helpers with one generic one, live with it for a week, and see whether the next person who touches that code is happier or more confused. That’s the only metric that matters.