- Book: The Complete Guide to Go Programming
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
An engineer I know spent two days arguing with their team about a single type parameter. The function was a four-line Map over a slice. Half the team wanted to keep it generic, the other half wanted to write three concrete versions. Both sides cited "Go 1.18 generics" as if the language had not moved since 2022.
It has moved a lot. Type inference widened across 1.21 and the releases that followed, and the call-site noise mostly went away. The 1.21 stdlib added slices, maps, and cmp.Ordered, which collapsed most of the boilerplate problem into a one-liner. Go 1.26 shipped errors.AsType, which finally killed the out-pointer dance.
This post is the 2026 cut: the patterns that work cleanly today, the pitfalls that still bite, and the rule of thumb that survives both lists.
What works: type inference is finally invisible
The single biggest change between 1.18 and 2026 is not a new feature. It is that you stop typing [T] at call sites. Go 1.21 widened inference to cover untyped constants, inference through interface methods, and generic functions passed as arguments to other generic functions. Releases since have closed most of the remaining gaps around partial inference for multi-parameter generics.
The practical effect: the call site reads like a normal Go function call.
import "slices"
nums := []int{3, 1, 4, 1, 5}
slices.Sort(nums) // no [int]
i := slices.Index(nums, 4) // no [int]
ok := slices.Contains(nums, 9) // no [int]
Your own generic functions get the same treatment, as long as the type parameter appears in a regular argument:
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
}
users := []User{ /* ... */ }
byCountry := GroupBy(users, func(u User) string {
return u.Country
})
No GroupBy[User, string]. The compiler reads the slice element type and the closure return type and threads them through. If you tried generics in 1.18 and bounced off the syntax, try them again. The call-site experience is a different language now.
What works: collection ops with comparable and cmp.Ordered
The two constraints that carry 90% of real-world generics are both in the standard library. comparable matches anything you can == (every primitive, structs of comparables, interfaces). cmp.Ordered matches anything you can < (integers, floats, strings, time.Duration).
Map, Filter, and Reduce read cleanly now:
func Map[T, U any](in []T, f func(T) U) []U {
out := make([]U, len(in))
for i, v := range in {
out[i] = f(v)
}
return out
}
func Filter[T any](in []T, keep func(T) bool) []T {
out := in[:0:0]
for _, v := range in {
if keep(v) {
out = append(out, v)
}
}
return out
}
func Reduce[T, A any](
in []T, seed A, step func(A, T) A,
) A {
acc := seed
for _, v := range in {
acc = step(acc, v)
}
return acc
}
Three functions, every type, no boilerplate. Pair them with slices.Sort, slices.SortFunc, and cmp.Compare and you have the kind of collection toolkit that used to be a 200-line utils.go in every Go service.
The Go team did not put Map/Filter/Reduce in the standard library, and that is intentional. A for loop is usually clearer than three chained generic calls. The win isn't that you should adopt a fluent style; it's that if the helper pulls its weight, the language no longer fights you.
What works: Result and Maybe, sparingly
The third pattern that holds up is small typed wrappers around return values. Two show up often: Result[T, E] for fallible operations where you want the error type pinned, and Maybe[T] (also called Optional[T]) for "value or nothing" without resorting to a pointer.
type Result[T any, E error] struct {
val T
err E
ok bool
}
func Ok[T any, E error](v T) Result[T, E] {
return Result[T, E]{val: v, ok: true}
}
func Err[T any, E error](e E) Result[T, E] {
return Result[T, E]{err: e}
}
func (r Result[T, E]) Unwrap() (T, error) {
if r.ok {
return r.val, nil
}
return r.val, r.err
}
Maybe[T] is shorter and more useful in domain code where *int would lie about whether absence means "missing" or "explicitly zero":
type Maybe[T any] struct {
val T
ok bool
}
func Some[T any](v T) Maybe[T] { return Maybe[T]{v, true} }
func None[T any]() Maybe[T] { return Maybe[T]{} }
func (m Maybe[T]) Get() (T, bool) { return m.val, m.ok }
A small warning: do not paint your whole codebase with these. Idiomatic Go is (T, error) and (T, bool). Result and Maybe earn their keep when you cross a boundary that needs a single return value: channels, futures, JSON encoding of optional fields. Inside ordinary handler code, the multi-return form is shorter.
What still doesn't work: methods can't add type parameters
The most-asked feature still hasn't shipped: a method cannot introduce its own type parameter beyond what the receiver already declares.
type Stream[T any] struct{ items []T }
// LEGAL: T comes from the receiver
func (s Stream[T]) Each(f func(T)) {
for _, v := range s.items {
f(v)
}
}
// ILLEGAL: U is a new method-level parameter
// func (s Stream[T]) Map[U any](
// f func(T) U,
// ) Stream[U] { ... }
The workaround is a top-level generic function that takes the stream as its first argument:
func StreamMap[T, U any](
s Stream[T], f func(T) U,
) Stream[U] {
out := make([]U, len(s.items))
for i, v := range s.items {
out[i] = f(v)
}
return Stream[U]{items: out}
}
// usage
nums := Stream[int]{items: []int{1, 2, 3}}
strs := StreamMap(nums, strconv.Itoa)
It works. It also kills method-chaining, because StreamMap(StreamFilter(StreamMap(s, f), p), g) reads inside-out instead of left-to-right. Builder-style fluent APIs in Go remain a non-starter for this exact reason.
The proposal to allow type parameters on concrete methods (golang/go#77273) is now Proposal-Accepted, but it sits on the Go team's backlog without a target release as of early 2026. Plan around it.
What still doesn't work: type sets that explode into any
The other pitfall is more insidious because the code compiles. You write a constraint that looks generic, and the compiler quietly falls back to the slow path under the hood.
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
}
Two problems live here. First, the constraint is 13 lines to express "any number," the kind of ceremony that nudges readers back toward interface{} and a runtime switch. Second, Go's generics use GC-shape stenciling: the compiler generates one specialised body per memory layout, not per type. For a constraint this wide, you pay an indirect call on every += because the compiler cannot inline through the union. On hot paths over slices of int64, a hand-written func sumInt64(xs []int64) int64 will out-perform the generic Sum. It isn't catastrophic. It isn't free either.
The practical rule: if your constraint is wider than comparable or cmp.Ordered, ask whether two concrete copies would be clearer and faster. Often they are.
The rule that survives
After four years, the rule is short. Reach for generics when the type parameter eliminates a type assertion and the constraint stays narrow (comparable or cmp.Ordered, or a one-method interface). Skip them when the constraint becomes a paragraph, when you find yourself wanting a method-level type parameter, or when the duplication you are trying to remove is two five-line functions.
Everything else (Result[T, E], Maybe[T], your own Map/Filter/Reduce) is a judgement call. Use them where they pull their weight, drop them where they make the call site harder to read.
The good news in 2026 is that the language no longer punishes the judgement call. Inference is invisible, the constraints you actually need are in the standard library, and the remaining limitations are small and documented. Go generics aren't Java, and they aren't the 1.18 mess people remember. They're a small, sharp tool, and four years of stdlib work has shown which shapes the tool fits.
If this was useful
The Go book has a chapter on generics that walks the same decision rule with longer worked examples, including the parametric-method workaround, the stenciling cost, and how to read a generic stack trace when something goes sideways at runtime.

Top comments (0)