Generics arrived in Go 1.18 (2022); by 2026, the patterns settled. The community’s stance is consistent: use them where they help, don’t where they don’t. This post is the working set.

When generics help

func Map[T, U any](xs []T, f func(T) U) []U {
    out := make([]U, len(xs))
    for i, x := range xs {
        out[i] = f(x)
    }
    return out
}

doubled := Map([]int{1, 2, 3}, func(x int) int { return x * 2 })

Without generics: per-type Map function or []interface{} casts. Generics: clean, type-safe, no casts.

Standard library uptake

The standard library added slices, maps, cmp, iter packages:

import "slices"
import "maps"

slices.Contains(xs, 5)
slices.Sort(names)
slices.Index(xs, target)

m := maps.Clone(other)
keys := maps.Keys(m)

Use these. They cover ~80% of what teams used to roll themselves.

Constraints

type Numeric interface {
    ~int | ~int32 | ~int64 | ~float32 | ~float64
}

func Sum[T Numeric](xs []T) T {
    var s T
    for _, x := range xs { s += x }
    return s
}

~int matches int AND any type whose underlying type is int.

golang.org/x/exp/constraints (still useful) defines Ordered, Integer, Float, etc.

Repository pattern

type Repo[T any] struct { db *sql.DB; table string }

func (r *Repo[T]) FindByID(ctx context.Context, id int64) (*T, error) {
    var t T
    // ... reflection or sqlx-style scan ...
    return &t, nil
}

Generic repo for many entity types. Watch out: pure-generic CRUD often gets messy. Many shops keep concrete repos for clarity.

Functional helpers

Common ones:

func Filter[T any](xs []T, p func(T) bool) []T { /* ... */ }
func Reduce[T, A any](xs []T, init A, f func(A, T) A) A { /* ... */ }
func GroupBy[T any, K comparable](xs []T, key func(T) K) map[K][]T { /* ... */ }

Use sparingly. for x := range xs with an explicit append is often clearer than Map/Filter/Reduce chains.

Sets

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

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

func (s *Set[T]) Add(x T)            { s.m[x] = struct{}{} }
func (s *Set[T]) Contains(x T) bool   { _, ok := s.m[x]; return ok }
func (s *Set[T]) Remove(x T)         { delete(s.m, x) }

Useful. Generics make this clean.

Channels and goroutines

type Pipeline[T, U any] struct {
    work chan T
    out  chan U
}

func (p *Pipeline[T, U]) Run(workers int, f func(T) U) {
    for i := 0; i < workers; i++ {
        go func() {
            for t := range p.work {
                p.out <- f(t)
            }
        }()
    }
}

Worker pools that don’t lose type info.

Type sets vs interfaces

type Stringer interface { String() string }      // method set
type Numeric  interface { ~int | ~float64 | ... } // type set

Method sets define behavior; type sets define which concrete types are allowed. Both are valid constraints.

When generics hurt

1. One concrete type

// BAD
type Repo[T any] struct {...}
var userRepo Repo[User]
// ... only ever used with User ...

// GOOD
type UserRepo struct {...}

If you have one type, write one type.

2. Generic returns when interface{} would do

func MustGet[T any](key string) T for config: rarely justifies the syntax overhead vs string specific.

3. Generic method receivers (don’t exist)

Methods can’t introduce new type parameters beyond the receiver’s. Workaround: top-level functions.

4. Reflection + generics

Generics don’t replace reflection (no field access, no struct inspection). For struct-heavy code: reflection still wins.

5. Constraint complexity

func F[T interface{ ~[]E; comparable }, E any](xs T, target E) bool { ... }

If your constraint reads like a riddle, consider concrete types.

Performance

Go generics use “GC stenciling” — different methods generated per pointer-shape. Roughly:

  • int vs int64 vs string use different codegen.
  • *T for any T shares.

Performance is usually within 5–10% of hand-written specialization. Negligible. If profiling shows it matters: hand-roll.

iter package (Go 1.23+)

import "iter"

func Numbers() iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := 0; i < 10; i++ {
            if !yield(i) { return }
        }
    }
}

for n := range Numbers() {
    fmt.Println(n)
}

Generic iterators. Range over functions. Composable. The slices/maps packages now expose iterator versions too.

Common mistakes

1. Genericizing too early

YAGNI. Wait for the second use case before generic-ifying.

2. Constraint over-engineering

15-line constraint where comparable would do. Keep it minimal.

3. Generic interfaces holding generic state

Get into “where do I store this T?” trouble. Often: a non-generic interface + concrete generic struct is cleaner.

4. Pointer vs value confusion

type Cache[T any] struct { ... }
func (c *Cache[T]) Get(k string) T { ... }

If T is large, you’re copying it. If pointer, shared mutability. Decide deliberately.

5. Forgetting comparable

Map keys / set elements need comparable. Compiler tells you, but the constraint isn’t transitive — explicitly require it.

What I’d ship today

For Go services:

  • slices, maps, cmp — use them.
  • Domain-specific generics when there’s a clear duplication.
  • Repository pattern: judgment call; concrete is fine for most.
  • Resist generic-by-default; concrete types win readability.

Read this next

If you want my Go generic helpers (Set, Map/Filter/Reduce that work, repos), it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .