Idiomatic Generics
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.goOutput:
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.goOutput:
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.goOutput:
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.goOutput:
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, andcmpover a houseFilter/Map/Reducechain. - 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
- In
receipt.go, trycheaper(t, d)wheretis aTicketanddis aDrink. Read the error. That mixed pair belongs intotal, not incheaper. - Add
func ids[T Identified](list []T) []inttoidentified.goand print the ticket IDs. - Delete
convertfrom your brain. Writefunc centsOf(t Ticket) int { return t.Cents }instead whenever you know the type.