52. When to use generics — and when not to

📖 Reading · 11 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

Generics are the newest big feature in Go, which makes them the most over-applied. The Go team's own guidance is unusually blunt: write code, not types. Start concrete, and reach for a type parameter only when you have actual duplication in front of you.

Three cases where generics are right

1. Container types. A stack, queue, set, cache, tree or linked list is the textbook case: the logic is genuinely identical for every element type.

package main

import "fmt"

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

func NewSet[T comparable](values []T) *Set[T] {
    s := &Set[T]{items: make(map[T]struct{}, len(values))}
    for _, v := range values {
        s.Add(v)
    }
    return s
}

func (s *Set[T]) Add(v T)      { s.items[v] = struct{}{} }
func (s *Set[T]) Has(v T) bool { _, ok := s.items[v]; return ok }
func (s *Set[T]) Len() int     { return len(s.items) }

func main() {
    langs := NewSet([]string{"go", "rust", "go", "zig"})
    fmt.Println(langs.Len(), langs.Has("go"), langs.Has("java"))

    ids := NewSet([]int{1, 2, 2, 3})
    fmt.Println(ids.Len(), ids.Has(2))
}

Go still has no built-in set. This is the standard fix, and it's better than map[string]bool copy-pasted five times.

(A compiled Go program would usually declare that constructor variadic — NewSet[T comparable](values ...T), called as NewSet("go", "rust"). The in-browser interpreter can't handle a variadic type parameter, so the box above takes a slice instead.)

2. Slice and map helpers. Operations on any slice, where the element type never matters:

package main

import "fmt"

func Chunk[T any](items []T, size int) [][]T {
    if size <= 0 {
        return nil
    }
    var out [][]T
    for i := 0; i < len(items); i += size {
        end := i + size
        if end > len(items) {
            end = len(items)
        }
        out = append(out, items[i:end])
    }
    return out
}

func Reverse[T any](items []T) []T {
    out := make([]T, len(items))
    for i, v := range items {
        out[len(items)-1-i] = v
    }
    return out
}

func main() {
    fmt.Println(Chunk([]int{1, 2, 3, 4, 5}, 2))
    fmt.Println(Chunk([]string{"a", "b", "c"}, 2))
    fmt.Println(Reverse([]int{1, 2, 3}))
}

3. Removing a any-plus-assertion API. If your existing code takes any and immediately asserts, generics are strictly better — same flexibility, but the compiler checks it and the caller gets a real type back.

Where an interface is the better tool

This is the distinction that matters most:

package main

import (
    "fmt"
    "strings"
)

type Shape interface {
    Area() float64
}

type Rect struct{ W, H float64 }
type Circle struct{ R float64 }

func (r Rect) Area() float64   { return r.W * r.H }
func (c Circle) Area() float64 { return 3.14159 * c.R * c.R }

func TotalArea(shapes []Shape) float64 {
    total := 0.0
    for _, s := range shapes {
        total += s.Area()
    }
    return total
}

func main() {
    shapes := []Shape{Rect{3, 4}, Circle{1}}
    fmt.Printf("%.2f\n", TotalArea(shapes))
    fmt.Println(strings.Repeat("-", 20))
}

TotalArea needs behaviour (Area()), and it needs a mixed slice of different shapes. That's an interface, and a generic version would be worse — []Shape holding a Rect and a Circle is the requirement, and []T where T is one concrete type can't express it.

The test:

you need use
different types, same behaviour (methods) an interface
different types, same code (logic doesn't care) generics
a heterogeneous collection an interface
a homogeneous container of one caller-chosen type generics

Where neither is right: just write the function

The most common misuse is generifying something used once:

// over-engineered — there is one caller and it passes []string
func ProcessItems[T any](items []T, fn func(T) error) error { ... }

// clearer, and easier to change later
func ProcessNames(names []string) error { ... }

Ask: do I have two or more concrete implementations right now? If the answer is no, the type parameter is speculation. Concrete code is easier to read, easier to debug, and takes ten seconds to generify later when the second caller actually arrives.

The real costs

Generics aren't free:

  • Readability. func Process[K comparable, V any, R Ordered](m map[K]V, f func(K, V) R) []R is a puzzle. Concrete signatures are self-documenting.
  • Error messages. A constraint violation produces a much longer, less obvious compiler error than a plain type mismatch.
  • A little runtime cost. Go doesn't specialise per instantiation the way C++ does, so generic code over value types can carry a small indirection overhead that a concrete function doesn't.
  • Contagion. A generic type tends to force generics on everything that touches it.

A worked decision

Say you're writing a function to find the highest-scoring item.

package main

import "fmt"

type Player struct {
    Name  string
    Score int
}

// Version 1: concrete. Start here.
func TopPlayer(players []Player) (Player, bool) {
    if len(players) == 0 {
        return Player{}, false
    }
    best := players[0]
    for _, p := range players[1:] {
        if p.Score > best.Score {
            best = p
        }
    }
    return best, true
}

// Version 2: generic, once a SECOND type genuinely needs it.
func TopBy[T any](items []T, score func(T) int) (T, bool) {
    var zero T
    if len(items) == 0 {
        return zero, false
    }
    best := items[0]
    for _, v := range items[1:] {
        if score(v) > score(best) {
            best = v
        }
    }
    return best, true
}

type Team struct {
    Name   string
    Points int
}

func main() {
    players := []Player{{"Ada", 90}, {"Grace", 95}, {"Alan", 88}}
    teams := []Team{{"Red", 12}, {"Blue", 30}}

    p, _ := TopPlayer(players)
    fmt.Println("top player:", p.Name)

    p2, _ := TopBy(players, func(p Player) int { return p.Score })
    t, _ := TopBy(teams, func(t Team) int { return t.Points })
    fmt.Println("generic:", p2.Name, "and", t.Name)
}

Version 1 is right when there's one type. Version 2 earns its keep at the moment Team shows up — and notice it needs a score function, which is extra API surface the concrete version didn't need. That's the trade, made visible.

The rule, one line

Write the concrete version first. Generify when you're copying it for the second time, with the third already in sight.

Your turn

Write a generic GroupBy that turns a slice into a map keyed by whatever the key function returns:

even: [2 4 6]
odd: [1 3 5]
package main

import "fmt"

// write GroupBy[T any](items []T, key func(T) string) map[string][]T

func main() {
    nums := []int{1, 2, 3, 4, 5, 6}

    groups := GroupBy(nums, func(n int) string {
        if n%2 == 0 {
            return "even"
        }
        return "odd"
    })

    fmt.Println("even:", groups["even"])
    fmt.Println("odd:", groups["odd"])
}
package main

import "fmt"

func GroupBy[T any](items []T, key func(T) string) map[string][]T {
    out := make(map[string][]T)
    for _, v := range items {
        k := key(v)
        out[k] = append(out[k], v)
    }
    return out
}

func main() {
    nums := []int{1, 2, 3, 4, 5, 6}

    groups := GroupBy(nums, func(n int) string {
        if n%2 == 0 {
            return "even"
        }
        return "odd"
    })

    fmt.Println("even:", groups["even"])
    fmt.Println("odd:", groups["odd"])
}

That's the language. The last three modules are about using it: the standard library, testing and tooling, and capstone projects.