Idiomatic Generics

Updated

September 13, 2026

Idiomatic Generics

Idiomatic generics are short constraints, obvious instantiations, and no type puzzles. If a reviewer needs a whiteboard to parse the signature, use an interface or a concrete type instead.

Mental model

Pick the tool from the job:

Job Tool
Several types share methods; the caller only needs those methods Interface
An algorithm or container must return the same concrete type it received Type parameter
One type at every call site That type, no generics, no interface

Constraints should read like English: cmp.Ordered, comparable, or a tiny interface you named (interface{ ID() int }). Union constraints (int | int64 | float64) are fine when the body uses operators those types share. Stacking three anonymous interfaces with ~ and method sets is how signatures rot.

Worked examples

Case 1: Interface for behavior, generic for the type you need back

Anything with a Price() int can go on a receipt. That is an interface. Finding the cheaper of two values of the same type is a generic (or the built-in min). Mixing them is the usual mistake: a generic Pricer[T] that only calls Price().

Save as receipt.go:

// receipt.go
package main

import "fmt"

type Pricer interface {
    Price() int
}

type Ticket struct {
    ID    int
    Cents int
}

func (t Ticket) Price() int { return t.Cents }

type Drink struct {
    Name  string
    Cents int
}

func (d Drink) Price() int { return d.Cents }

func total(items []Pricer) int {
    sum := 0
    for _, it := range items {
        sum += it.Price()
    }
    return sum
}

func cheaper[T Pricer](a, b T) T {
    if a.Price() <= b.Price() {
        return a
    }
    return b
}

func main() {
    t := Ticket{ID: 41, Cents: 1250}
    d := Drink{Name: "tea", Cents: 300}
    fmt.Println("total", total([]Pricer{t, d}))

    a := Ticket{ID: 1, Cents: 900}
    b := Ticket{ID: 2, Cents: 400}
    fmt.Printf("cheaper ticket %d\n", cheaper(a, b).ID)
}

Run:

go run receipt.go

Output:

total 1550
cheaper ticket 2

total takes []Pricer because the slice holds mixed types and you only need Price(). cheaper is generic because the result must still be a Ticket (so .ID type-checks). If cheaper took Pricer, the return would be an interface and .ID would not compile. That is the whole distinction.

Case 2: A constraint that stays readable

Name the constraint. Put the method set in one place. The function signatures stay short.

Save as identified.go:

// identified.go
package main

import "fmt"

type Identified interface {
    ID() int
}

type Ticket struct{ N int }

func (t Ticket) ID() int { return t.N }

type Shift struct{ N int }

func (s Shift) ID() int { return s.N }

func lookup[T Identified](list []T, id int) (T, bool) {
    var zero T
    for _, v := range list {
        if v.ID() == id {
            return v, true
        }
    }
    return zero, false
}

func main() {
    tickets := []Ticket{{41}, {42}, {43}}
    t, ok := lookup(tickets, 42)
    fmt.Printf("ticket %d ok=%t\n", t.N, ok)

    shifts := []Shift{{1}, {2}}
    s, ok := lookup(shifts, 9)
    fmt.Printf("shift %d ok=%t\n", s.N, ok)
}

Run:

go run identified.go

Output:

ticket 42 ok=true
shift 0 ok=false

Identified is three lines. Readers do not have to parse T interface{ ID() int } in every signature. Reuse the name in tests and docs.

Case 3: Let slices express the algorithm

Sorting tickets by cents does not need a generic desk type. It needs slices.SortFunc.

Save as sort_tickets.go:

// sort_tickets.go
package main

import (
    "fmt"
    "slices"
)

type Ticket struct {
    ID    int
    Cents int
}

func main() {
    tickets := []Ticket{
        {ID: 41, Cents: 1250},
        {ID: 42, Cents: 400},
        {ID: 43, Cents: 800},
    }
    slices.SortFunc(tickets, func(a, b Ticket) int {
        return a.Cents - b.Cents
    })
    for _, t := range tickets {
        fmt.Printf("%d:%d\n", t.ID, t.Cents)
    }
}

Run:

go run sort_tickets.go

Output:

42:400
43:800
41:1250

No OrderedTicket interface. No Sortable[T]. A function value and a standard-library generic. That is the idiomatic shape.

The trap

Type gymnastics: converting through any to “make it generic,” or a constraint that lists every type you might ever meet.

Save as gym.go:

// gym.go
package main

import "fmt"

func convert[A, B any](a A) B {
    return any(a).(B)
}

func main() {
    n := convert[int, int](12)
    fmt.Println(n)
    defer func() {
        fmt.Println("panic:", recover())
    }()
    fmt.Println(convert[int, string](12))
}

Run:

go run gym.go

Output:

12
panic: interface conversion: interface {} is int, not string

The signature claims any A becomes any B. The body is a type assertion. You get a generic that panics — the worst of both worlds. If you know the types, write a function that takes them. If you do not, you cannot convert them.

A related smell is a constraint like interface{ ~int | ~int8 | ~int16 | ~int32 | ~int64; String() string; Price() int } invented so one function can do three jobs. Split the function.

The boring rule

  • Interface when you need methods and mixed concrete types.
  • Type parameter when you need the concrete type back, or a container of T.
  • Name constraints. Keep them one idea.
  • Prefer slices, maps, and cmp over a house Filter / Map / Reduce chain.
  • Go 1.27 generic methods are allowed. A package-level function is still easier to search and test. Use a method when it is clearly about that type.
  • If the body contains any(v).(T), you are not writing generics. You are writing a type assertion with extra syntax.

Try this

  1. In receipt.go, try cheaper(t, d) where t is a Ticket and d is a Drink. Read the error. That mixed pair belongs in total, not in cheaper.
  2. Add func ids[T Identified](list []T) []int to identified.go and print the ticket IDs.
  3. Delete convert from your brain. Write func centsOf(t Ticket) int { return t.Cents } instead whenever you know the type.