Image by Andrew Teoh

if err != nil
Musings about error handling syntax in Go
9 min. read

With generics out of the door since Go 1.18, improving the error handling experience is now high up in the list of Go’s most wanted language features. Although there are countless proposals1 around dedicated error handling syntax already, so far none of them was convincing enough to find its way into the standard. With this blog post, I’m going to add yet another idea to that pile – you know, just for the sake of completeness.

For context on the code samples throughout this post, imagine we had a hardware random number generator (HRNG) attached to our computer. To interact with that device from our Go code, there were a package called hrng. It exposes a function called Random, which composes a fresh random integer by means of the HRNG-device. The operation might fail, for example when the hardware connection is suddenly interrupted.

package hrng

func Random() (int, error) {
	// → Request bytes from HRNG-device
	// → Convert bytes to `int` and return
}

2

Current state of affairs

Unlike Java or Python, Go doesn’t have a throw-like exception mechanism. The idiomatic way for communicating errors is by making use of the fact that functions in Go can have multiple return values: to indicate failure, it’s conventional to return an object of type error as last value from the function:

That being said, if we wanted to use hrng.Random() in an application – e.g., for printing a random number to the console – then we would write it like this:

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

There are a few ups and downs with the error handling paradigm in Go. The bright side is that the resulting code is simple and explicit. The potential existence of a failure is obvious, so it’s hard to miss. The same is true for the control flow of the part that’s handling the error: the return statement inside the if block makes it clear how the failure is handled, i.e. that printRandomNumber causes an early exit and propagates the error back to the caller.

One often-expressed concern is the verbosity of that pattern. Go applications typically contain countless instances of it, which can add significant noise to the code. For any one fallible expression, there are at least three lines of rather trivial error handling code.

The repetition of the check also opens room for human fault – just think you accidentally wrote if err == nil instead of if err != nil (which I have, and not just once).

The boilerplate grows with the number of return values increasing, because each of them has to be provided with an “arbitrary” value. (Usually the “zero value” is used, but it can be really any value.)

func createManyThings() (MyStruct, string, int, error) {
	err := doSomething()
	if err != nil {
		return MyStruct{}, "", 0, err
	}
	// ...
}

The issue with scope

Personally, I don’t mind the verbosity of Go’s error handling paradigm too much. I actually prefer to clearly see potential failure paths in my code, and I’m fine with “paying” a few extra lines to get that benefit. My main issue is rather something else: the scoping of the variables.

From a logical perspective, success and failure are mutually exclusive states. In case of success, the “success” values are significant, and the “error” value is absent; whereas in case of failure, the “success” values are void, and the “error” value is present. By convention, there should only ever be one of those two situations at any given time.

However, the language neither reflects nor enforces that. Instead, all return values are released together into the same scope. Initially, it’s unclear in what state they are, so we have to manually inspect one of them (namely the “error” one) in order to determine how we are supposed to treat the other ones.

After we are done with the error check, the “error” variable remains in the scope, even though it doesn’t serve a purpose anymore. That’s not just inelegant, but it can create actual problems: if we were to carry out multiple subsequent operations that might potentially fail, we are either forced to re-assign (and thus re-purpose) one error variable, or we have to come up with distinct but arbitrary names for them. Neither of these two options is ideal, though.

func doThings() error {
	err := doThis()
	if err != nil {
		return err
	}
	// We are re-purposing the initial `err` variable
	// for the second error. In contrast to the initial
	// assignment via `:=`, we have to use `=` below.
	// That is, unless `doThat` returns more than one
	// value, in which case it’s `:=` again.
	err = doThat()
	if err != nil {
		return err
	}
	// ...
	return nil
}
func doThings() error {
	err1 := doThis()
	if err1 != nil {
		return err1
	}
	// We are using distinct (yet somewhat arbitrary)
	// names for the errors. We have to be careful,
	// though, to not mix them up, and accidentally
	// use `err1` for `err2` – like below.
	err2 := doThat()
	if err1 != nil {
		return err2
	}
	// ...
	return nil
}

Ideas for new syntax

For me, the ideal error handling syntax would be as explicit and straightforward as the current one. In addition, it would provide strict and tidy scoping, and it would also carry out the error check automatically.

Here is what I have in mind:

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

By preceding the function call with a try keyword, we indicate that the “error” return value of the function doesn’t have a matching variable on the left-hand side of the assignment, as it would normally do. Instead, the “error” return value is captured by the “handle” part on the right.

The “handle” part, which follows the function call expression, consists of three components:

If the “error” return value is non-nil (i.e., err != nil), it is assigned to the error variable, and the handler block is invoked. The name of the error variable can be chosen freely, and the error variable is scoped to the handler block. There is otherwise nothing special about the handler block3: it adheres to all scoping rules that would apply to, say, a regular if block.

The try mechanism is only available on functions whose last return value is of type error – otherwise, the code wouldn’t compile. The rules would be flexible enough to allow for other common error handling use-cases, such as wrapping up the error, or invoking a fallback procedure without exiting.

func printRandomNumber() error {
	number := try hrng.Random() handle err {
		// Wrap the original error into our custom one,
		// and return to the caller.
		return fmt.Errorf("failed to generate number: %w", err)
	}
	fmt.Printf("Random number: %d", number)
	return nil
}
func printRandomNumber() error {
	number := try hrng.Random() handle err {
		// Write a log message, and then proceed by
		// falling back to a virtual pseudo-random
		// number generator (PRNG).
		logging.Errorf("HRNG failure: %w", err)
		number = prng.Random()
	}
	fmt.Printf("Random number: %d", number)
	return nil
}

In case the invoked function doesn’t return any other value apart from the error, the preceding assignment would be omitted.

func main() {
	try printRandomNumber() handle err {
		fmt.Println(err)
	}
}

Discussion

So, let’s rip this apart.4

The bright side: I think the syntax would still be sufficiently explicit, so both the error itself and the control flow are expressed directly through code, just like before. The scope separation is sufficiently strict, and it reflects the actual logical structure. The new block scope avoids polluting the subsequent code with void error variables. The handling mechanism is obvious and straightforward, so there is no implicit “magic” to it.

Of course, this comes at a cost. The syntax would require two additional keywords, try and handle (or whatever they are called), which is a non-negligible factor for a slim language like Go. Although these keywords might be somewhat self-explanatory, it’s still a new language construct that has to be learned and understood. The most critical bit is probably how the error variable disappears from the left-hand side of the assignment, and instead is processed by the “handle” part on the right.

The proposed syntax would still be similarly verbose as before, but I don’t think that brevity is a valid concern here. After all, error handling isn’t an accidental afterthought, but it’s an inherent and substantial matter of every program, with intrinsic complexity in its own right. Therefore, it shouldn’t be covered up only because it might look unpleasant.

At the end of the day, it all comes down to the question whether the gained benefits would justify dedicated syntax. That, however, is tricky to answer: to play devil’s advocate, the proposed syntax is essentially just syntactic sugar that aims to “standardise” a use-case that can already be solved with built-in mechanisms. On the other hand, error handling is an integral and persistent concern in practice, so it should be properly accounted for by the language, and not just rely on mere convention. It’s not for nothing that the error handling topic has long been (and is still being) debated intensely in the Go community.


  1. For an overview of existing proposals, the issues in the Go repository might be a good starting point. ↩︎

  2. The exact implementation is not important, we only need to care about the signature here. ↩︎

  3. Note: the handler must be a block, not a function, because otherwise you couldn’t issue a return to the enclosing scope. But of course there can be a function call inside the handler block. ↩︎

  4. Disclaimer: for now, this is nothing more than an idea. It might not be novel (see e.g. this post), and there probably are technical implications that I haven’t considered. ↩︎