I’ve been writing about Go on this blog since 2015. Back in that “What I like about Go” article I talked about simplicity, fast compilation, and how good it feels to use a language that doesn’t force you to memorize twenty ways of doing the same thing. Then Go 1.18 arrived with generics in 2022, and with them an asymmetry that has bothered a lot of people for years: you could write generic types and generic functions, but not generic methods.
Mark Freeman’s article on the official Go blog, published on August 26th, closes that chapter. Go 1.27 adds type parameters to methods on concrete types.
The asymmetry that stung
Go 1.18 solved the classic problem: before it, you had to write one linked list for integers and another one for strings.
// before Go 1.18
type ListOfInts struct {
elem int
next *ListOfInts
}
type ListOfStrings struct {
elem string
next *ListOfStrings
}
// after Go 1.18
type List[E any] struct {
elem E
next *List[E]
}
Same with functions: SortInts and SortStrings collapsed into a single Sort[E cmp.Ordered]. Perfect.
But methods were left out. The original generics proposal reasoned that since generic interface methods are hard to implement efficiently, it didn’t make sense to add type parameters to concrete methods either. In that view, methods are primarily the means of implementing an interface, so if one half can’t exist, neither should the other.
Where it hurt in practice
The example in the article is very clear. You have your List[E] and you want to transform it into a list of some other type. You can write a specific method:
func (List[E]) ToString(f func(E) string) List[string] { /* ... */ }
And it works fine for concrete cases:
fmt.Println(NewList(1, 2, 3).ToString(strconv.Itoa)) // [1 2 3]
The problem is that parameterizing List lets you generalize the source type, but not the destination type, because the destination depends on the transformation you apply. If you only have one destination type, fine. If you have many, you need a generic function:
// after Go 1.18
func MapList[E, R any](l List[E], f func(E) R) List[R] { /* ... */ }
And that brings two drawbacks. The first is organizational: MapList lives in package scope, and if several data types support mapping operations, the package fills up with loose functions. The second is readability: chained calls have to be written inside out.
fmt.Println(MapList(MapList(NewList(0, 2, 4), add(2)), divideBy(2))) // [1 2 3]
Anyone who has written a chain of three or four transformations knows what reading that feels like.
What Go 1.27 brings
// after Go 1.27
func (List[E]) Map[R any](f func(E) R) List[R] { /* ... */ }
Compact, expressive, and scoped locally to the type. And chained calls read left to right, the way you’d expect:
fmt.Println(NewList(0, 2, 4).Map(add(2)).Map(divideBy(2))) // [1 2 3]
If for some reason you prefer the “inside out” form, method expressions still work with generic methods:
f := List[int].Map[int]
fmt.Println(f(f(NewList(0, 2, 4), add(2)), divideBy(2))) // [1 2 3]
Like every other generic in Go, generic methods must be instantiated — explicitly or implicitly — before being used.
The change of mindset
What I find most interesting about the article isn’t the syntax, it’s the reasoning. The Go team has decoupled two concepts that used to travel together: methods as the mechanism for implementing interfaces, and methods as a tool for organizing code.
A generic concrete method can’t help implement an interface — that’s still true. But it can still be useful for organizing code. And that second use is worthwhile on its own.
The article illustrates it with a subtle case:
type I interface {
M()
}
type T struct{} // a struct, not an interface
func (T) M[P any]() { /* ... */ } // a generic *concrete* method
Any instantiation of T.M produces a signature identical to I.M — both are func M(). But that doesn’t mean T implements I. Interface implementation is a property of the type, not of any particular method: T doesn’t declare T.M[int], it declares its uninstantiated generic counterpart T.M.
Why there are no generic interface methods
This is the part that explains the eight-year wait, and it’s worth understanding because it says a lot about how Go compiles.
An interface value is a box that can hold a value of any type implementing it. When you call i.M(), in a simple program it’s obvious the call routes to T.M. But in real programs that relation spans package boundaries and is impossible to deduce in the general case.
// -- package main --
import "p"
func main() {
p.F(T{})
}
type T struct{}
func (T) M() { /* ... */ }
// -- package p --
func F(i I) {
i.M()
}
type I interface {
M()
}
The two packages are compiled separately, so main doesn’t know what p.F does with T{}. The compiler’s current solution is straightforward: it generates code for all non-generic methods of a type at its declaration or instantiation. That way, whatever p.F does, the code will exist at runtime.
Now imagine I.M were generic:
// -- package p --
func F(i I) {
i.M[int]()
}
type I interface {
M[P any]()
}
To cover any possible use of T{} inside p.F, the compiler would have to instantiate T.M with every possible type argument. That’s impractical with Go’s approach, which generates specific code for each instantiation.
There is an alternative: if type arguments were “boxed” — passed as values of their constraint interfaces — every instantiation would share the same code. You’d avoid the explosion of instantiations, but you’d pay for indirect calls instead, even on direct calls to already-instantiated methods. That’s exactly the kind of trade-off Go has been refusing for years, and it seems consistent to me.
My take
Go is still the language I liked in 2015, for reasons that haven’t changed: it prefers solving 90% of the problem with a solution that has no hidden cost, over solving 100% of it and paying in performance or complexity. Generic methods are a good example. We don’t get the complete version of the feature, we get the half that can be implemented well.
And that half is the one you actually use day to day. Writing Map, Filter or Reduce as methods on your own types, with readable chaining, covers the vast majority of cases where generic methods were missed. Generic interface methods are a compiler design problem that may get solved some day, or may not.
Eight years between the generics proposal and this missing piece. In an ecosystem where every six months a new language shows up promising to solve everything, that slow and deliberate cadence remains, for me, one of the best things about Go.









I’ve always liked to see and try different technologies, and within these, of course, programming languages.


Comments