Generic Methods

Updated

September 13, 2026

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.go

Output:

[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.go

Output:

[$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.go

Output:

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.go

Output:

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.go

Output:

<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

  1. In ticket_list.go, add func (l *List[E]) Filter(keep func(E) bool) *List[E] (no new type parameter) and keep only even ticket IDs.
  2. Rewrite Case 2 using only package-level MapList helpers. Compare the nested call to the method chain.
  3. In desk_rand.go, assign f := (*rand.Rand).N[int] and call f(r, 10). Confirm it matches r.N(10).
  4. 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.