Skip to content

Go Generics in 2026: The Type Parameters I Actually Reach For

Go Generics in 2026: The Type Parameters I Actually Reach For

Confession: when Go 1.18 shipped generics in 2022, I wrote one Map function, felt clever, and then went back to copy-pasting filter loops for about eighteen months. I told myself the syntax was ugly. It wasn’t. I just didn’t have the reps.

Four years later I’ve actually integrated golang generics into the parts of my codebase where they pay rent. I’ve also kept a small list of places I tried them and then quietly reverted. This post is that list. Not a tutorial, not a feature tour, just the type parameters I now reach for without thinking and the ones I learned to leave alone.

If you want the official version of “what are generics,” go read the original Go blog announcement and the tutorial in the docs. They’re both short and clear. This post is the part nobody writes: which patterns survive contact with real code.

The before that finally annoyed me

Here’s the thing that turned me. I had three almost-identical functions in a webhook handler:

func firstNonZeroInt(vals ...int) int {
    for _, v := range vals {
        if v != 0 {
            return v
        }
    }
    return 0
}

func firstNonZeroString(vals ...string) string {
    for _, v := range vals {
        if v != "" {
            return v
        }
    }
    return ""
}

func firstNonZeroDuration(vals ...time.Duration) time.Duration {
    for _, v := range vals {
        if v != 0 {
            return v
        }
    }
    return 0
}

Three copies of nine lines. The diff was the type. I’d been telling myself interface{} was fine for years. Reader, interface{} was not fine. Every caller had to type-assert or use reflect, and the test cases multiplied.

The generic version is one function:

func FirstNonZero[T comparable](vals ...T) T {
    var zero T
    for _, v := range vals {
        if v != zero {
            return v
        }
    }
    return zero
}

comparable is the constraint that lets me say v != zero. The var zero T line is the only Go-specific oddness. After I shipped this, four other functions in the same package collapsed the same way. The codebase got smaller. That almost never happens.

The constraint pattern I actually use

Most go generics tutorials show you any or comparable and stop. The interesting one is constraints.Ordered, which lives in the golang.org/x/exp/constraints package. It’s every type that supports < and >: ints, floats, strings, and their named variants.

The version I keep in an internal utilities package:

import "golang.org/x/exp/constraints"

func Clamp[T constraints.Ordered](v, lo, hi T) T {
    if v < lo {
        return lo
    }
    if v > hi {
        return hi
    }
    return v
}

I use this all over the place. Rate limiter token counts, animation easing values in a small frontend tool I built, retry backoff bounds I’ve grown attached to. Before generics I had clampInt, clampFloat64, and clampDuration. Now I have one function that handles all three plus the ones I haven’t written yet.

Two things I learned the hard way. First, don’t define your own Ordered constraint by hand. People do it and then forget about float types or the named-type case. Use the standard library one. Second, custom constraints are great until you find yourself with seven of them. If the constraint takes longer to read than the function body, you’re probably building the wrong abstraction. Drop back to two concrete functions and move on.

Generic data structures: yes, mostly

I waited a long time before using generics on structs because the syntax in the type declaration is the ugliest part of the feature. It still is. But the practical value is high enough that I got over it.

The pattern that earned its place in my code:

type SafeMap[K comparable, V any] struct {
    mu sync.RWMutex
    m  map[K]V
}

func NewSafeMap[K comparable, V any]() *SafeMap[K, V] {
    return &SafeMap[K, V]{m: make(map[K]V)}
}

func (s *SafeMap[K, V]) Get(k K) (V, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.m[k]
    return v, ok
}

func (s *SafeMap[K, V]) Set(k K, v V) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.m[k] = v
}

That’s a thread-safe map I used to write four times per service. Each version had a slightly different bug because I was reaching for sync.Mutex instead of sync.RWMutex in the one I wrote at 1am. One generic version, one set of tests, one bug surface.

The standard library finally caught up on the read side: sync.Map exists, and there are typed wrappers in flight. For most workloads I still prefer my own typed wrapper because the API is clearer and the types make the tests obvious. If you’re hitting real contention, benchmark it. Don’t take my word.

This kind of small infrastructure decision is the sort of thing I talk through in my Go concurrency patterns post, and the same instinct applies here. Reach for the typed wrapper before the untyped clever thing.

Where I tried generics and reverted

This is the section nobody writes, so here it is.

I tried writing a generic Result[T] type because I miss Rust’s Result whenever I’m five layers deep in a request handler:

type Result[T any] struct {
    Value T
    Err   error
}

I built helpers around it. I converted three packages. Then I had to interop with the rest of the codebase, which uses Go’s regular (value, error) return tuple. Every boundary was a conversion. New contributors looked at the codebase and asked which one they were supposed to use. I removed the Result type and never looked back.

The lesson: golang generics let you build patterns from other languages. That doesn’t mean you should. Idiomatic Go is (T, error), and fighting that costs more than it saves. If you want sum types, you want a different language. I wrote about that tradeoff in Rust vs Go in 2026.

I also tried a generic Pipeline[In, Out] type for streaming transformations. It worked. It read like a Haskell book. Nobody on the team could maintain it. I deleted it.

The new bits in Go 1.23+

Two updates since the original release are worth knowing about. The Go 1.23 release notes introduced range-over-func, which composes really well with generics. You can write a generic iterator helper once and use it across slices, maps, and channels. I’ve started using this in a small ETL pipeline I maintain for a freelance client.

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) {
                continue
            }
            if !yield(v) {
                return
            }
        }
    }
}

This is the first time iterators feel native in Go to me. Combined with type parameters, you can compose pipelines without reinventing them per type.

The second is the slow but real expansion of golang.org/x/exp/slices and maps into the standard library. slices.Contains, slices.IndexFunc, maps.Keys, maps.Values. These existed in user packages forever. Now they’re standard and people stop arguing about which third-party utility library to import. If you haven’t deleted your custom Contains helpers yet, the time is now.

How I decide whether a function should be generic

Here’s the rule I landed on after building too many clever things:

  • If I’m about to write the same function for a second type, I make it generic.
  • If the constraint takes longer to read than the body, I don’t.
  • If the function operates on a struct with named fields, it shouldn’t be generic. It should be an interface or a regular function on a regular type.
  • If the generic version forces every caller to write [int] or [string] at the call site, the inference isn’t doing enough work and I’m probably forcing a pattern.

Type inference improved a lot from 1.18 to 1.21 and again in 1.22. If you wrote off generics two years ago because the call site looked noisy, look again. Most of my code now infers cleanly without explicit type arguments.

I keep these patterns in a small toolbelt repo I started maintaining when I redesigned my portfolio site, and I add to it whenever I find a new pattern that survives a month of real use. The barrier I use is “did I copy-paste this function again last week?”

Try this week

If you write Go regularly, here’s the thing to do this week: grep your codebase for functions whose names end in a type, like FilterInts or MaxFloat64. If you find two with the same body, you have your first refactor. The diff will be small, the test changes minimal, and the codebase will be slightly less embarrassing.

If you find no duplicates, you probably don’t need generics. That’s fine. Type parameters are a tool that pays off when you have repetition. They are a tax when you don’t.