Photo by Robert Anasch

fail err
Musings about error handling syntax in Go (2/2)
6 min. read

Warning: the following post explores crazy ideas around error handling syntax in the Go programming language. Unlike normally on my blog, it’s considered okay to stop reading this post at any point. If you are sensitive to thought experiments around language design in Go, you are advised to take preliminary measures to leave this page promptly in case you feel sudden discomfort.

In a previous blog post, I discussed an idea for a dedicated error handling syntax for the Go programming language. The aim was to separate the success case and the error handling in distinct scopes on the call side. That way, the language would reflect more accurately how the two statuses of success and failure are supposed to be mutually exclusive code paths, and it would avoid multiple consecutive error paths to interfer with each other.

As reminder, this was the code sample to demonstrate the proposed error handling syntax on the call side:

func printRandomNumber() error {
	number := try hrng.Random() handle err {
		return err
	}
	fmt.Printf("Random number: %d", number)
	return nil
}

In this follow-up post, I want to expand on this idea, and explore how the same principle can be adopted to the function side. After all, if we conclude that scope separation would be worthwhile on the call side, it’s only logical to have a similar mechanism within the invoked function as well.

Current state of affairs

With current Go syntax, the implementation of the hrng.Random function could look like this:

package hrng
import ("encoding/binary"; "errors")
var device = // ...

func Random() (uint64, error) {
	bytes := device.requestBytes(8)
	if len(bytes) < 8 {
		return 0, errors.New("not enough bytes from HRNG device")
	}
	number := binary.BigEndian.Uint64(bytes)
	return number, nil
}

In order to satisfy the uint64 return type of our Random function, we need the HRNG device to yield 8 bytes to us. However, let’s say that the device implementation cannot guarantee to return precisely 8 bytes, due to some obscure internal, technical reasons. Therefore, we have to check the length of the byte array, and return an error in case our HRNG device leaves us high and dry.

The return keyword forces us to always specify values for all return arguments. In case of an error, however, the uint64 argument doesn’t have any meaning, yet we still have to come up with an arbitrary value for it. The convention is to use the zero value of the respective type (0 in this case), but in practice you could use any value – 9, 42, 87631289, whatever fancies you. In the face of the error, the caller is supposed to disregard it.

Either way, the resulting code is not really self-explanatory, and you can only make fully sense of it if you are aware of that convention. But even then, it still can look confusing at times, especially in cases where a zero value appears both as legitimate success value and as void placeholder in close vicinity. Apart from all that, it can just be tiresome to spell out arbitrary zero values over and over again for no good reason.

Separating the exits

So what if the kind of the outcome (success or failure) was indicated by means of a dedicated keyword?

func Random() (uint64, error) {
	bytes := device.requestBytes(8)
	if len(bytes) < 8 {
		fail errors.New("not enough bytes from HRNG device")
	}
	number := binary.BigEndian.Uint64(bytes)
	return number
}

The fail keyword would basically behave like return in the sense that it exits the function. Argument-wise, it would only cover the right-hand, error-related values, though – the success-related values wouldn’t have to be specified. Note, that we also changed the semantics of the return keyword: return now would only apply to the success-related values, without having to be followed by the once obligatory nil.

These semantics of return and fail would not just spare us redundant and meaningless zero values, but they would also express the nature and intent of the function exit very clearly. That way, you could quickly scan the function body, and immediately see the exit conditions with their respective values.

We could even take it one step further, and make the function signature reflect this duality by means of a union-like syntax for the list of return values:

func Random() uint64 | error { /*...*/ }

func DoSomething() (bool, string) | error { /*...*/ }

Discussion

The following code snippet demonstrates all ideas coming together:

func main() {
	number := try Random() handle err {
		fmt.Println(err)
		return
	}
	fmt.Printf("Random number: %d", number)
}

func Random() uint64 | error {
	bytes := device.requestBytes(8)
	if len(bytes) < 8 {
		fail errors.New("not enough bytes from HRNG device")
	}
	return binary.BigEndian.Uint64(bytes)
}

Given common practices and conventions in Go programming, such language constructs might seem conclusive, as they verbalise the idioms that the Go community follows already. However – as with all things in life – they would also introduce downsides and pitfalls. (They are also not backwards-compatible, by the way, but that is an entirely different story.)

Functional programming people might wrinkle their nose at us for how we basically invented a complicated and unflexible syntax for a concept that’s well known as monads: if we wanted scope separation and more expressive signatures, why not just use a generic Either type and call it a day?

The proposed syntax also optimises for conventions that might be wide-spread on the one hand, but that are still not necessarily ubiquitous. We are improving clarity here, but we might take away or complicate things elsewhere. Also, consider the following potential issues:

To wrap it up, although I think error handling in Go has its quirks in practice, I still find the overall situation bearable. In the end, there is nothing inherently wrong with lose conventions, even if they aren’t as neat and rigorous as dedicated, specialised language constructs. Language design is a tricky balancing act, and sometimes it’s better to embrace certain trade-offs if that contributes to keeping things sane and simple overall. Don’t we all know how adding one well-meant convenience after the other can turn a language into a hot, convoluted mess? (Yes, I’m looking at you, JavaScript.)

My e-mail is: (Click anywhere to close.)