Were multiple return values Go's biggest mistake?

Yet another article complaining about Go’s error handling.

A Gopher.
A Gopher.
Toggle original/dithered image

tl;dr: Multiple return values in Go interact poorly with other language features. We should probably promote them to full-blown tuple types.

The Go programming language is frequently critizied for all sorts of reasons. To name a few crowd favorites: Lack of sum types, lack of enums, lack of null safety, lack of const correctness or immutability guarantees, verbose error handling that still makes it easy to accidentally ignore errors, the infamous time formatting and of course the lack of generics (this one is admittedly outdated).

All of this is normal. Sickos like me love discussing perceived shortcomings of programming languages. It’s nothing personal. I like Go, even, despite its shortcomings.

My personal candidate for Go’s worst historical mistake is that of multiple return values, exactly because of how harmless they look.1 Multiple return values influenced the design of the language (in bad ways), interact poorly with other features, and make the language much more complex than it has to be.

Exhibit A, multiple return values:

func foo() (int, error) {
	return 123, nil
}

Looking at this, you’d be forgiven for thinking that Go has ’tuples’ (like Python or Rust).

It doesn’t. There’s no such thing as a ’tuple’. All Go has is a special case syntax which allows functions to return multiple values at the same time. If you want something that sort of behaves like a tuple in Go, you need to define a struct.2

There’s a few other cases of this ‘multiple return values’ behavior special cased for the built-in range operator or when accessing a value from a map or channel.

nums := map[int]string{
	0: "zero",
	1: "one",
}
for key, value := range nums { // extract two values at a time
	fmt.Printf("Key: %d, Value: %s\n", key, value)
}
val := nums[1] // extract value associated with key or zero value
val, ok := nums[2] // 'comma, ok' pattern to check presence
if !ok {
	fmt.Print("Key not found")
}

What I’m complaining about in this screed is that—as a consequence of putting multiple return values into the language and not making them a dedicated tuple type—Go is in a worse state than it had to be, and that all of this was completely avoidable.

Data Structures

When I use other languages, I tend to be pretty confident that whatever one of my functions spits out, I can put it into a list or vector.

Not so in Go!

Through the power of Go, it’s impossible to just pass data around without additional ceremony. I can’t overstate how strange it is that in the world of Go, calling a function and being able to store the result in a list is the exception, and not the rule.

func doStuff() (string, error) { ... }

func doSomeStuff() {
	var results [](string, error)
	for range 10 { // did you know that this syntax is legal nowadays?
		results = append(results, doStuff())
	}
}

This (of course) doesn’t compile since (string, error) is not a type. Tuples don’t exist. So you better get used to first refactoring all of your functions and defining a whole new struct just to pass some data around. Maybe use two lists? Or a new struct? Amazing.

This gets significantly more annoying once you try to go concurrent.

Imagine that you want to spin up some goroutines, have each call func doStuff() (string, error), and then gather the results. You cannot pass (string, error) through a channel since it’s not a standalone type. The usual workaround is to do whatever this is or to define a custom struct:

func foo() {
	type Result struct {
		Val string
		Err error
	}
	ch := make(chan Result)
	go func() {
		val, err := doStuff()
		ch <- Result{
			Val: val,
			Err: err,
		}
	}()
	// ...
}

Let me repeat this: Go—a language famous for its concurrency and uncompromising errors-as-values approach—requires you to define a non-standard wrapper type to handle errors as soon as its most basic synchronization primitive is involved or to store function results in a slice.

In other words, Go’s error handling and concurrency don’t compose.

Hell, even Go’s built-in containers and error handling don’t compose!

Like many of Go’s problems, it’s something you can work around, but I don’t think that that’s much of an excuse.

The whole idea behind Go’s error handling is that errors are values. You can pass them around like values, inspect them like values, and they’re handled using the same control flow constructs as everything else.

So despite errors being values, the return of a function call is in general not a value at all. You can’t pass it around, you can’t store it.

I don’t want to sound mean, but a friend of mine has suggested that he believes that “Rob Pike invented Go as a practical joke.”, and things like this really make me wonder if he has a point. Why would you design a language where the result of a function call cannot be stored or passed around?

The problem is that this situation was avoidable—if anyone had thought about this in slightly more detail back in the earliest design days—it would’ve been easy to just promote multiple return values to anonymous structs. Instead that time was, presumably, spent adding nonsense like named return values.

This is about simplicity. Nothing is ‘simple’ about the fact that calling a function doesn’t return a value. It’s a weird idiosyncrasy. By trying to make the language ‘simple’, you played yourself and added a weird edge case.

Iterators and ‘Range over Functions’

Go 1.23 allows you to range over functions (and integers, for that matter, though I can’t imagine that Rob Pike is very happy about it).

Here’s a basic example of ranging over functions:

func count(n int) func(yield func(int) bool) {
	return func(yield func(int) bool) {
		for i := 0; i < n; i++ {
			yield(i)
		}
	}
}

func main() {
	// we can range over count(3) since it's a function of type
	// func[V any](yield func(V) bool)
	for i := range count(3) {
		fmt.Printf("%v, ", i) // 0, 1, 2,
	}
}

Just create a constructor function which returns a function which captures variables from the enclosing function scope, and uses them to decide when to call its argument function, thereby determining the behavior of the loop for the end-user. Nothing could possibly be easier. (That’s sarcasm. I find this code hard to read and would’ve preferred an interface-based approach.)

The Go standard library defines the following types and functions as part of this release:

// function types that can be iterated over, returning one or two values per iteration
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)

// convert pull-style into push-style iterators
// time yourself to see how long it takes to parse the function definitions
func Pull[V any](seq Seq[V]) (next func() (V, bool), stop func())
func Pull2[K, V any](seq Seq2[K, V]) (next func() (K, V, bool), stop func())

Go cannot handle the case of iterating over one or two values in a uniform, parametrized way. It requires duplicate definitions, one for handling one value at a time, and one for two values.

for val := range foo { ... } // you can do this
for k, v := range foo2 { ... } // this requires a different api
for x1, x2, x3 := range foo3 { ... } // this is literally impossible in Go

Why?

Because Go doesn’t have ’tuples’. Why doesn’t Go have tuples? I don’t know—There were some vague discussions in 2009, but it just looks like the Go developers didn’t think of the feature as particularly important. Keeping the language ‘small’ was the higher priority for them.

Again: This was an attempt to keep the language ‘simple’ which now results in weird, complex edge cases and having to learn twice as many APIs. Congratulations, you tried to simplify and instead just played yourself.

(Back in the day Go didn’t have generics. Designing a language that interacts poorly with generics under the assumption they’ll never be added was a design tradeoff that probably made sense at the time. Funfact: Go took 5 years until it’s 1.0 release. Rust needed 9 years, and Zig still isn’t there.)

Take a step back to think about what this means: The designers of the standard library are forced to hardcode specific instances of a type because the language is unable to sufficiently abstract over whether you’re handling one or two values at a time.

Again: This is not a hard technical problem. There are solutions for this. It’s not even hard if you design a language from the get-go. Go just didn’t implement them.

Consider what this means for Go’s library ecosystem. If even the standard library easily runs into situations where it’s necessary to work around this limitation, how hard do you think it is a library that does anything slightly fancier? (Again, this limitation extends to error handling, since error handling happens through multiple return values.)

Broken Error Handling

Warning: Hot opinions ahead, and a bunch of speculation.

Go’s multiple return value-based error handling was considered to be “good enough”, and prevented the development of a better approach to error handling.

In reality that could’ve gone either way: The Go developers considered enums and or-operators to be such mysterious, advanced features that there’s no guarantees Go would have ended up with Result-type based error handling even if multiple return values had never made it into the language.

For all we know we’d have “Go with exceptions”, which would probably be a step backwards.

Still: I’ll stand by the fact that multiple return values were a classic ‘worse is better’-type solution that prevented the Go developers from ever even considering anything even slightly more nuanced, like a Result type (aka a sum type purely for error handling, i.e. a value which is either a result or an error).

Annoyances

Instead, we’re stuck with multiple return values-based error handling. I’m sure you’ve seen people complain about the issues with accidental shadowing of variables. Those make it pretty easy to accidentally forget to check errors. Not a big deal but (once again, this is a running theme here) entirely unnecessary.

Or taking an example from here, that code like this compiles:

type Foo struct { ... }
func doStuff() (Foo, error) { ... }

// this compiles
// the values returned from doStuff() get passed as first and second
// argument to fmt.Println, which takes an arbitrary list of `any` values
// fmt.Println is not special, this also works for other functions
func main() {
	fmt.Println(doStuff())
}

Or the fact that, no, there’s nothing that stops you from writing functions like this here:

func whyWouldYouDoThis() (error, error, int) { ... }

Is this bad? Well, it’s by design.

I am not saying Go’s error handling is terrible. It’s okay. It’s decisively mid-tier, which is a bit embarrassing for a modern green-field project. They had an opportunity to do better than this, and they blew it, and many of these issues are downstream of the decision to standardize on multiple return values as the idiomatic way to handle errors.

Results

I’ll put my own take on what Go’s error handling should have been like in here since it’s my blog post and you cannot stop me. I’m sure some people will heavily disagree. That’s fine. I think that if Go had standardized around this approach to error handling everyone would’ve gotten used to it.

Simple, just define a Result[T, error] type that has a handle method or operator defined on it that either just passes the value or (if an error is present) wraps it and returns from the function.

You can bikeshed the hell out of this one. Make it a postfix operator instead of a method, if you want. Write handle! or try? to make it stand out more, I don’t care.

func parseInts(v []string) Result[[]int, error] {
	var results []int
	for _, s := range v {
		value := parseInt(s).handle("Failed to parse %v", s)
		results = append(results, value)
	}
	return results
}
  1. Vastly better interactions with generics.
  2. Allows you to store Result[Foo, error] in slices and pass it around however you please.
  3. Better for static analysis.
  4. Less verbose, if you care about that. (I honestly don’t.)
  5. There’s not even a need for sum types if you don’t want them.
  6. This gets you something like Rust’s ? operator from the get-go, without having to awkwardly try to glue it on later.

In practice, it’s too late for invasive changes like that: Go has standardized on multiple return values for error handling, and trying to move away from that would be a fool’s errant.

Backwards Compatibility

Since trying to move towards sum-type-based error handling is a nonstarter unless we want to split the language, let’s ask the obvious question: Is there anything that can be done to improve any of the issues outlined in this post?

Can we at least promote multiple return values to full blown types and allow generics over them?

Maybe. That’d be cool. I think it’d be an improvement.

It’s not entirely trivial though, so let me write down some reasons why it’d be hard, and the changes that’d have to be made.

You might say “But doesn’t Go have strong backwards compatibility guarantees?” Yeah, it does. That’s what makes it hard. If you could just change stuff however you want, it’d be easy.

That said, even Go 1.22 made a pretty significant change. You can get pretty far as long as you’re willing to say “Old code will continue to mean exactly what it means today: the fix only applies to new or updated code.”, and you provide tools to auto-fix code during a migration from Go 1.N to Go 1.(N+1).

In other words, it doesn’t sound impossible.

Unpacking

In Go, multiple return values are ‘unpacked’ via val, err := foo(). This makes the following code syntactically ambiguous due to the optional presence value you can extract from maps:

m := make(map[int](int, int))
a, b := m[0] // is this (int, int), bool or int, int?

Is this a problem? Eeeeh. It looks like one, but it’s to resolve by just picking one. Since tuples don’t exist in previous versions of Go, old code is just not affected.

Multiple Return Value passing

Passing multiple return values to a variadic function is currently legal Go code:

func foo() (int, error) { ... }
func bar(...any) { ... }

func main() {
	bar(foo()) // compiles in current Go, automatically unpacks
	val := foo()
	bar(val)
}

Does this pass the tuple val to bar as the first argument, or does it automatically unpack val to pass the fields as first and second argument?

Moving away from auto-unpacking would be a breaking change, but if we don’t move away from it then bar(foo()) != bar(val).

The modern solution to that is pretty simple: First, allow tuples to be unpacked like slices are (i.e. you have to write bar(foo()...) to unpack the tuple). Second, add go vet and go fix commands that identify this issue and fix it when upgrading to the most recent edition of Go.

Honestly? I thought this would be hard, but as far as language changes go, this seems pretty easy, all things considered.

If you want to dig deeper into this, you can find a bunch of similar Github issues on these topics. I stumbled upon them when doing research for this blog, I’ve not participated in any of them.

Maybe I should, though. It would be really nice to see this one issue finally resolved.

History

I don’t know for sure how Go ended up in this weird state with ‘multiple return values’, where it’s impossible to pass function results through a channel or into a slice.

That one is still baffling to me, so here’s me trying to make sense of it.

My understanding is that multiple return values were part of Go before its public release. Even in the Weekly Snapshot History that goes all the way back to 2009, there’s only a single mention of multiple return values way back in 2010: “cgo: correct multiple return value function invocations (thanks Christian Himpel)”.3

I imagine the situation played out as follows:

First of all, multiple return values entered the language somehow. Perhaps via the range operator, since someone figured that using a traditional for (int i = 0; i < 100; i++)-style loop just to iterate over the elements of an array or map is too error prone, or perhaps just as a convenience feature.

Then, the gates of hell opened, demons attacked and the world of programming was set back—no, sorry, I am kidding.

I assume that what actually happened is that multiple return values were just convenient and quickly became the idiomatic ‘gold-standard’ for error handling, passing values around, and so on.

At this point I’m sure that someone asked “Hey, why don’t we just promote those to a full-blown tuple type?”, and was shot down with something like “We already have structs. There should be a single way of doing things. Go is a simple language. Why would we want to have two features that do the same thing?”

In either case, at this point multiple return values were here to stay, and the feature established itself as the standard for error handling.

Looking at the oldest internal discussions in Google’s ‘Golang Nuts’ group (just one or three days after the first announcement) is interesting. Say here or here. I mean it! Go and take a look. Lines such as “Go doesn’t have nullable types, we haven’t seen a lot of demand for them” really puts things into perspective.

Conclusion

Give it a few years and we enter the present day, and Go is struggling with some of its earliest design decisions. (Evidence: This entire post. Also, generic methods are still impossible.)

Go has these weird special cases (e.g. multiple return values, named return values), decided that ‘and’ and ‘or’ are close enough (they even made that mistake twice), and many of these problems were intentional design decisions or avoidable.

The ‘avoidable’ part is a big deal for me. It just feels like a lot of pain could’ve been avoided if Go had spent slightly more time thinking about programming language design.

Going out on a limb, to me it looks like many of the issues boil down to the question of whether you take types seriously, and are willing to dig into the bare minimum of abstractions to figure out how certain features need to be designed, instead of just making things up as you Go™.

I don’t want to shill for Rust again, but one thing that language did well is that it took types seriously and designed itself around its type system.

That is how it got memory safety without a garbage collector. By moving that information to the type system. This is exactly, intentionally the road that Go didn’t pick.

Go instead got…multiple return values, which are specifically, intentionally not a type in their own right to keep the language simple. I think this was a bad decision. When it’s easy to codify an abstraction as a type, you should codify it as a type. Go is still learning that lesson today.

To go out on a positive note—I just spent a whole post complaining about Go, after all—I think that Go is an impressive technical achievement, and set the modern gold standard as far as tooling goes. I am grateful for that. I also really have to respect that its simplicity keeps perfectionism at bay.

Someday I want to write a post about what I like about Go, but that day is not today. I’ll cross my fingers that tuples are going to be on the list by then.

In the meantime, I just created a mailing list, so add yourself if you want to be notified for whatever I write next.


  1. There are a lot of other candidates! Don’t take this too seriously. It’s not a race, y’know. I like this one specifically since it looks so innocent, but had pretty far-reaching effects. ↩︎

  2. Go has anonymous structs, ie. structs without a name. If you want to try using one, pick some part in your code where you’re referring to a struct and just inline the struct definition. For example, func bar(data struct{}) {} is a legal function definition. They have very limited uses. The main one which I see regularly is table-driven tests↩︎

  3. I don’t know who Christian Himpel is or what he’s doing nowadays, but I wish him all the best. :-) ↩︎