DEV Community

Cover image for Go Generics, 4 Years In: The 3 Cases Where They're the Right Answer
Gabriel Anhaia
Gabriel Anhaia

Posted on

Go Generics, 4 Years In: The 3 Cases Where They're the Right Answer

๐Ÿ“š This post pairs with two books I've written on Go. Book 1: The Complete Guide to Go Programming. Book 2: Hexagonal Architecture in Go. Or both together as the Thinking in Go collection: Kindle / Paperback. Short blurbs at the bottom.

Series - Thinking in Go

Go generics shipped four years ago, in March 2022. Most teams still can't agree on whether to use them. One camp treats every new helper function as an excuse to reach for [T any]. The other camp writes the same MaxInt, MaxFloat64, MaxDuration three times because "generics add complexity."

Both camps are wrong, and we have evidence now. Four years is long enough for the Go team to decide for themselves what belongs behind a type parameter and what doesn't, and they've been quietly writing that decision into the standard library. If you read the stdlib's generics adoption as a style guide, the answer is simpler than the arguments suggest.

This post is that style guide, with the actual code they shipped. By the end you'll have a one-sentence decision rule for "should this be generic?" that you can apply in code review without any more hot takes.

What the Go team actually shipped

Since 1.18, the Go team has added generics to a very specific, very short list of places in the standard library:

  • slices (1.21): slices.Contains, slices.Index, slices.Sort, slices.SortFunc, and friends. All generic over element type.
  • maps (1.21): maps.Clone, maps.Keys, maps.Values, maps.Equal. Generic over key and value.
  • cmp (1.21): cmp.Ordered constraint, cmp.Compare, cmp.Or. Used by slices.SortFunc and anywhere you need ordering without < operator magic.
  • sync (1.21): sync.OnceValue, sync.OnceFunc, sync.OnceValues. Type-safe lazy initialization.
  • errors.AsType[T] (1.26): the new generic version of errors.As. Returns the typed error directly instead of using an out-pointer.
  • reflect (1.26): new iterator methods like reflect.Type.Fields() and reflect.Value.Methods() that return iter.Seq2 values.

Here's what they did not generify, despite four years of opportunity:

  • io: no io.Read[T], no io.Buffer[T].
  • net/http: no typed handler, no typed middleware stack.
  • database/sql: no Query[T], no generic Row.
  • encoding/json: no json.Unmarshal[T].
  • container/list: still uses any. Not one type parameter added, four years later.

That last one is the most telling. The Go team literally had a container library with any everywhere, they had generics, and they chose not to generify it. That's not an oversight. That's the style guide.

The pattern that emerges: generics are for algorithms over data structures you own (sort, max, contains, lazy init) and for functions where the type parameter eliminates a type assertion (errors.AsType). Generics are not for framing up Java-style abstractions over interfaces, ports, or request handlers.

Now let's look at the three use cases that actually pay off.

Use case 1: algorithms over collections

The canonical win. Before generics, every Go codebase had a copy of contains for strings, another for ints, another for their own struct types. Maybe a few of them were hand-written with type switches. All of them were boilerplate.

// pre-generics. write once per type.
func ContainsString(s []string, target string) bool {
    for _, v := range s {
        if v == target {
            return true
        }
    }
    return false
}
Enter fullscreen mode Exit fullscreen mode

Post-generics, with slices.Contains:

import "slices"

_ = slices.Contains([]string{"a", "b"}, "a")
_ = slices.Contains([]int{1, 2, 3}, 2)
Enter fullscreen mode Exit fullscreen mode

One function, every comparable type. The comparable constraint does the heavy lifting. Your own code should look like this too when you're writing reusable algorithms over collections:

// a real example: grouping slice elements by a key function
func GroupBy[T any, K comparable](items []T, keyFn func(T) K) map[K][]T {
    out := make(map[K][]T, len(items))
    for _, it := range items {
        k := keyFn(it)
        out[k] = append(out[k], it)
    }
    return out
}
Enter fullscreen mode Exit fullscreen mode

Clean, reusable, no interface{} in sight. This is the use case generics were designed for, and you should reach for it without hesitation.

Use case 2: numeric and ordered constraints

The second use case is when your function needs to compare or arithmetic values, and you don't want four copies of the same function for int, int64, float64, and time.Duration.

Before cmp.Ordered:

func MaxInt(a, b int) int         { if a > b { return a }; return b }
func MaxInt64(a, b int64) int64   { if a > b { return a }; return b }
func MaxFloat64(a, b float64) float64 { if a > b { return a }; return b }
// ... and so on, forever
Enter fullscreen mode Exit fullscreen mode

Go 1.21 shipped cmp.Ordered, which matches any type that supports < (all integer types, floats, strings, duration). One function, all of them:

import "cmp"

func Max[T cmp.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

_ = Max(3, 5)                          // int
_ = Max(3.1, 5.2)                      // float64
_ = Max("alpha", "beta")               // string
_ = Max(1*time.Second, 2*time.Second)  // time.Duration, nice bonus
Enter fullscreen mode Exit fullscreen mode

cmp.Ordered is the constraint you want for 90% of the "I need to compare two things of the same type" use case. If your function needs something more exotic (say, two values that implement a custom Less method), you can use slices.SortFunc and a comparison callback instead of inventing your own constraint.

One line of nuance: don't write a generic wrapper around a single concrete type. If your function only ever needs to compare int64, just take int64. Generics cost a little readability and sometimes a little runtime performance. If you're not getting reuse, you're paying the cost for nothing.

Use case 3: type-safe lazy init and error extraction

Two of the cleanest generic wins in the stdlib show up here, and one of them is new in Go 1.26.

First, sync.OnceValue[T] (Go 1.21). Before it, every team had some version of this:

var (
    cfgOnce sync.Once
    cfg     *Config
)

func getConfig() *Config {
    cfgOnce.Do(func() {
        cfg = loadConfig()
    })
    return cfg
}
Enter fullscreen mode Exit fullscreen mode

With sync.OnceValue, you get the same thing with half the code and no package-level variables:

var getConfig = sync.OnceValue(loadConfig)

// later
c := getConfig()
Enter fullscreen mode Exit fullscreen mode

loadConfig runs exactly once, the first time getConfig() is called. Every subsequent call returns the cached value. Generic over the return type, no type assertion, no package state.

Second, errors.AsType[T], new in Go 1.26. Before it, the idiomatic pattern was:

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    // pathErr is now typed
    log.Println(pathErr.Path)
}
Enter fullscreen mode Exit fullscreen mode

That works, but the out-pointer dance is ugly and error-prone. With errors.AsType:

if pathErr, ok := errors.AsType[*os.PathError](err); ok {
    log.Println(pathErr.Path)
}
Enter fullscreen mode Exit fullscreen mode

Cleaner, more obvious at call sites, type parameter in exactly the right place. If you're writing error-handling helpers in your own packages, this is the pattern to copy for any "extract a typed error" utility. Don't write the errors.As version anymore.

New in Go 1.26: self-referential type parameters

Go 1.26 added a language feature that had been blocked for four years: type parameters can now reference themselves in their own constraints.

type Adder[A Adder[A]] interface {
    Add(A) A
}

func Sum[A Adder[A]](vals []A) A {
    var total A
    for _, v := range vals {
        total = total.Add(v)
    }
    return total
}
Enter fullscreen mode Exit fullscreen mode

Before 1.26 you literally couldn't write Adder[A Adder[A]] because the compiler refused. Now you can, and it unlocks the "F-bounded polymorphism" pattern that languages like Java and Rust use for things like builders and monoids.

Is it useful in day-to-day Go? Honestly, rarely. The situations where you need a type parameter that refers to itself are real but narrow: generic builders, generic comparables that return their own type, custom monoid-style combinators. If you're writing any of those, 1.26 just became a lot more pleasant. If you're not, this feature is a "good to know it exists" note more than a "change how you write Go" moment.

Watch out for people using it as a hammer. If you see a PR with type Foo[F Foo[F]] interface in it, ask "what were we trying to do here?" before you approve. Nine times out of ten the answer is "make the types line up for a use case that didn't need generics at all."

Anti-pattern 1: generic "repository" or "service" types

The most common [T any] mistake in real Go codebases:

// looks elegant, is a trap
type Repository[T any] interface {
    Save(ctx context.Context, entity T) error
    FindByID(ctx context.Context, id string) (T, error)
    Delete(ctx context.Context, id string) error
}
Enter fullscreen mode Exit fullscreen mode

This is a Java pattern wearing Go clothes. It doesn't fit the language, and the Go team didn't generify anything like it in the stdlib because it's the wrong abstraction.

Why it breaks: the set of operations a repository supports depends on the entity. A UserRepository needs FindByEmail. An OrderRepository needs FindByUserID. A DocumentRepository needs Search. None of those fit into a generic interface, so the moment you try to add them you end up with a generic base and a concrete extension, and now you're writing Go like it's Spring Boot.

The Go way is to write one interface per repository, tailored to the entity, and let the concrete type define the operations it actually supports:

type UserRepository interface {
    Save(ctx context.Context, u User) error
    FindByID(ctx context.Context, id string) (User, error)
    FindByEmail(ctx context.Context, email string) (User, error)
}

type OrderRepository interface {
    Save(ctx context.Context, o Order) error
    FindByID(ctx context.Context, id string) (Order, error)
    FindByUserID(ctx context.Context, userID string) ([]Order, error)
}
Enter fullscreen mode Exit fullscreen mode

Two interfaces. Zero type parameters. Every operation is specific to the entity that needs it. This is how the stdlib models things, and it's how your code should model them too.

Anti-pattern 2: [T any] with no useful constraint

If your generic function body is a type switch, you don't want generics. You want any and a type switch:

// bad
func Process[T any](v T) string {
    switch x := any(v).(type) {
    case int:
        return fmt.Sprintf("int: %d", x)
    case string:
        return fmt.Sprintf("str: %s", x)
    default:
        return "unknown"
    }
}
Enter fullscreen mode Exit fullscreen mode

The type parameter is doing no work. You've added generic syntax and the type assertion is still the actual mechanism. Just take any:

func Process(v any) string {
    switch x := v.(type) {
    case int:
        return fmt.Sprintf("int: %d", x)
    case string:
        return fmt.Sprintf("str: %s", x)
    default:
        return "unknown"
    }
}
Enter fullscreen mode Exit fullscreen mode

Simpler, same runtime behavior, no false advertising about type safety at the call site.

Anti-pattern 3: generics-for-DRY

This is the subtle one. Someone writes two similar 4-line functions:

func sumInts(xs []int) int {
    var total int
    for _, x := range xs {
        total += x
    }
    return total
}

func sumFloats(xs []float64) float64 {
    var total float64
    for _, x := range xs {
        total += x
    }
    return total
}
Enter fullscreen mode Exit fullscreen mode

And decides to DRY them up with generics:

type Number interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
        ~float32 | ~float64
}

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

Now we have a 13-line type constraint to replace two 4-line functions. We saved 4 lines of duplicate logic and added 13 lines of ceremony. The original two functions were clearer, and any reader can see at a glance what they do. This is a net loss.

The rule: if the duplication is small and the constraint is large, just duplicate. Two copies of a 5-line function cost you 10 lines of obvious code. Generics cost you a constraint, a type parameter at every call site, and a reader who has to look up what ~int | ~float64 means. Duplicate the code. It's cheaper.

The decision rule

Here's the one-sentence test you can apply in code review:

If the Go team had this problem in the standard library, would they generify it?

Functions like Max, Contains, GroupBy, OnceValue, AsType? Yes, they would, and they did. Go generic without hesitation.

A Repository base interface, a handler-chain builder, a DI container, a "generic data access layer"? No, they wouldn't, and they didn't. Neither should you.

DRY glue over two similar 5-line functions? No. Just duplicate the five lines. It's cheaper.

Anything where the type parameter eliminates a type assertion and the constraint is small (comparable, cmp.Ordered, a one-method interface)? Yes, go generic.

That's the whole decision. It took the Go team four years and a dozen stdlib additions to write it. You can apply it today.

Next step

Grep your codebase for [T any]. For each hit, run the decision rule. Delete the ones that fail it. Keep the ones that pass.

Then grep for places you should be using slices, maps, cmp, sync.OnceValue, and (if you're on Go 1.26) errors.AsType. You probably have hand-rolled versions that you can replace with one-liners from the stdlib. That's the fastest possible generics refactor: delete your own code and call the standard library instead.

Question for the comments: what's the best and worst use of generics you've seen in a real Go codebase? I'm especially curious about the "looked clever, aged poorly" category.


The books

๐Ÿ“– The Complete Guide to Go Programming โ€” Book 1. The language from the ground up, including when generics help and when the interface model is still the better tool.

๐Ÿ“– Hexagonal Architecture in Go โ€” Book 2. How to build Go services with interfaces the way Go intended, not with generic abstractions stolen from other languages. 22 chapters + a companion repo.

๐Ÿ“š Or the full collection: Thinking in Go on Amazon as Kindle or Paperback.

Top comments (0)