Generic Methods
Generic Methods
Go 1.18 gave you generic functions and generic types. Helpers that belonged with a type still had to live as package-level functions (MapList, FilterTickets, …), so call sites nested inside out and the package namespace filled up. Go 1.27 lets a concrete method declare its own type parameters. Keep the helper on the type. Chain left to right. Do not put type parameters on interface methods — that is still impossible, on purpose.
Mental model
- A generic method looks like a method with a type-parameter list after the name:
func (List[E]) Map[R any](f func(E) R) List[R]. - The receiver’s type parameters (
E) and the method’s type parameters (R) are both in scope in the signature and body. - Instantiation works like generic functions: usually inferred at the call site, or written explicitly (
list.Map[string](...)). - A method expression turns a generic method into a function:
List[int].Map[string]. - Interfaces cannot declare type parameters on methods. A concrete generic method does not make the type implement an interface that has a same-named non-generic method. Methods that participate in interfaces stay non-generic.
- Prefer a package-level generic function when the operation is not really about one receiver. Prefer a generic method when the operation is clearly owned by that type and you want chaining.
Stdlib taste: math/rand/v2 kept the old typed helpers and added (*Rand).N[Int intType](n Int) Int so one method covers every integer width.
Worked examples
Case 1: Map on a desk ticket list
A generic List[E] already parameterizes the element type. Mapping to another element type needs a second type parameter on the method — that is what Go 1.18 could not put on the receiver.
Save as ticket_list.go:
// ticket_list.go
package main
import "fmt"
type List[E any] struct {
elem E
next *List[E]
}
func NewList[E any](elems ...E) *List[E] {
var head *List[E]
for i := len(elems) - 1; i >= 0; i-- {
head = &List[E]{elem: elems[i], next: head}
}
return head
}
func (l *List[E]) Map[R any](f func(E) R) *List[R] {
if l == nil {
return nil
}
return &List[R]{elem: f(l.elem), next: l.next.Map(f)}
}
func (l *List[E]) String() string {
if l == nil {
return "[]"
}
s := fmt.Sprintf("[%v", l.elem)
for p := l.next; p != nil; p = p.next {
s += fmt.Sprintf(" %v", p.elem)
}
return s + "]"
}
func main() {
ids := NewList(41, 42, 43)
labels := ids.Map(func(id int) string {
return fmt.Sprintf("T-%d", id)
})
fmt.Println(ids)
fmt.Println(labels)
}Run:
go run ticket_list.goOutput:
[41 42 43]
[T-41 T-42 T-43]
Map stays next to List. The package does not need MapList. The call reads left to right.
Case 2: Chain transforms without nesting
Package-level MapList forces inside-out nesting. A method chain stays readable.
Save as ticket_chain.go:
// ticket_chain.go
package main
import "fmt"
type List[E any] struct {
elem E
next *List[E]
}
func NewList[E any](elems ...E) *List[E] {
var head *List[E]
for i := len(elems) - 1; i >= 0; i-- {
head = &List[E]{elem: elems[i], next: head}
}
return head
}
func (l *List[E]) Map[R any](f func(E) R) *List[R] {
if l == nil {
return nil
}
return &List[R]{elem: f(l.elem), next: l.next.Map(f)}
}
func (l *List[E]) String() string {
if l == nil {
return "[]"
}
s := fmt.Sprintf("[%v", l.elem)
for p := l.next; p != nil; p = p.next {
s += fmt.Sprintf(" %v", p.elem)
}
return s + "]"
}
func main() {
// Cents on the desk → dollars as float64 → printed labels.
out := NewList(1250, 400, 800).
Map(func(cents int) float64 { return float64(cents) / 100 }).
Map(func(dollars float64) string { return fmt.Sprintf("$%.2f", dollars) })
fmt.Println(out)
}Run:
go run ticket_chain.goOutput:
[$12.50 $4.00 $8.00]
If you still want the function shape, take a method expression: f := (*List[int]).Map[float64], then call f(list, transform). Instantiation is required before the expression is a plain function value.
Case 3: One random helper for every integer width
Before 1.27, a seeded Rand needed IntN, Int32N, Int64N, …. Go 1.27 adds a generic method on the value: (*Rand).N[Int intType](n Int) Int. Same method, every integer width.
Save as desk_rand.go:
// desk_rand.go
package main
import (
"fmt"
"math/rand/v2"
)
func main() {
r := rand.New(rand.NewPCG(1, 2))
// Argument type picks Int; result type matches.
a := r.N(10)
b := r.N(int32(10))
c := r.N(int64(10))
fmt.Printf("%T\n", a)
fmt.Printf("%T\n", b)
fmt.Printf("%T\n", c)
// Values are deterministic for this seed; assert they stay in range.
if a < 0 || a >= 10 || b < 0 || b >= 10 || c < 0 || c >= 10 {
panic("out of range")
}
fmt.Println("in range")
}Run:
go run desk_rand.goOutput:
int
int32
int64
in range
You still have the old typed methods. Prefer N when the call site already knows the integer type you want back.
Case 4: Explicit instantiation when inference cannot see the result
Sometimes the type parameter appears only in the result. Spell it out.
Save as ticket_fold.go:
// ticket_fold.go
package main
import "fmt"
type Bag[E any] []E
func (b Bag[E]) FirstOr[R any](f func(E) R, fallback R) R {
if len(b) == 0 {
return fallback
}
return f(b[0])
}
func main() {
open := Bag[int]{41, 42}
empty := Bag[int]{}
fmt.Println(open.FirstOr(func(id int) string {
return fmt.Sprintf("ticket-%d", id)
}, "none"))
// Fallback is string; f's result is string — R is inferred.
fmt.Println(empty.FirstOr(func(id int) string {
return fmt.Sprintf("ticket-%d", id)
}, "none"))
// Prefer an explicit type argument when the call gets noisy:
fmt.Println(open.FirstOr[string](func(id int) string {
return fmt.Sprintf("#%d", id)
}, "none"))
}Run:
go run ticket_fold.goOutput:
ticket-41
none
#41
The trap
Treat a generic concrete method as if it implemented an interface. It does not.
Save as trap_iface.go:
// trap_iface.go
package main
import "fmt"
type Closer interface {
Close() error
}
type Gate struct{}
// Generic concrete method — legal on Gate, useless for Closer.
func (Gate) Close[T any]() error {
var zero T
_ = zero
return nil
}
func main() {
var c Closer
// Gate does not implement Closer: Close is generic, Closer.Close is not.
// Uncommenting the next line fails to compile:
// c = Gate{}
_ = c
fmt.Println(Gate{}.Close[int]())
}Run:
go run trap_iface.goOutput:
<nil>
If you need Closer, write func (Gate) Close() error. Keep generic helpers as separately named methods (CloseWith[T any](...)) that are not part of the interface set.
Second trap: inventing a generic method for a one-off that is clearer as a package-level function. Methods are for organization around a type, not for hiding every free function.
The boring rule
- Use a generic method when the operation belongs on that type and chaining or method-set locality helps readers.
- Use a generic function when the operation is shared across types or is easier to find at package scope.
- Instantiation is required (inferred or explicit) before you call the method or take a method expression.
- Never design an interface that needs type parameters on methods — the language will not grow that feature for free.
- Do not expect a generic method to satisfy a non-generic interface method of the same name.
- Mirror stdlib taste: keep old clear helpers if they are widely used; add a generic method when it collapses a family (
Rand.N).
Try this
- In
ticket_list.go, addfunc (l *List[E]) Filter(keep func(E) bool) *List[E](no new type parameter) and keep only even ticket IDs. - Rewrite Case 2 using only package-level
MapListhelpers. Compare the nested call to the method chain. - In
desk_rand.go, assignf := (*rand.Rand).N[int]and callf(r, 10). Confirm it matchesr.N(10). - Try to write
type M interface { Map[R any](func(int) R) }and read the compiler error. That limitation is the feature boundary, not a temporary gap.