Go 1.27 and Generic Methods: Eight Years in the Making
6 min read

Go 1.27 and Generic Methods: Eight Years in the Making

1122 words

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.

Comments

Latest Posts

2 min

349 words

A few days ago I came across an article that literally left me with my mouth open. It’s about TinyEMU-Go: a RISC-V emulator written entirely in Go, ported from C using Claude. And the best part: you can run a complete Linux with a single command.

The Command Line That Gave Me Envy

go run github.com/jtolio/tinyemu-go/temubox/example@2c8151233c2d

And boom, you have a complete Linux running. No special permissions, no containers, no weird dependencies. A pure static Go binary.

5 min

863 words

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

An example of this was the intensive use I gave at the time, at Arrakis, to Rebol, an interpreted cross-platform language, with everything you could need to perform great data cleaning scripts, in a “strange” syntax, but beautiful in its approach.

The case of Go was a bit different, because just like what happened to me with Angular, at the time (several years ago) I tried to give it a chance, but all the information and examples I found were “very small” blocks of code, I didn’t see in that (at a bird’s eye view) that it was finished, it seemed like a more academic/conceptual language than something for real use.

1 min

106 words

Options Pattern in Golang

Option pattern is a functional programming pattern that is used to provide optional arguments to a function that can be used to modify its behavior.

How to create a simple event streaming in Laravel?

Event streams provide you with a way to send events to the client without having to reload the page. This is useful for things like updating the user interface in real-time changes are made to the database.

5 min

939 words

Moments of change, moments of evolution, a constant in my life, with the 25th anniversary of the creation of the web I’ve entered “review” mode and I’m highly perplexed.

I’ve been doing things for the same time (25 years), enjoying, always with the same concept and particularity: Enjoyment and result, but it hasn’t been relevant for a long time (which is also not very relevant).

I’ve realized that the usual thing is to do things within your comfort zone, and I’ve never had one or knew one could exist, hence I’ve touched and done unusual things or perhaps “untimely”, when a certain technology could be in fashion “Buzz” I hadn’t been using it for a long time because it didn’t give me what something else gave me,…