if err != nil
Musings about error handling syntax in Go (1/2)
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() (uint64, error) {
// → Request bytes from HRNG-device
// → Convert bytes to `uint64` and return
}
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:
- In case of success, the “error” return value is
nil
. - In case of failure, the “error” return value is an object of type
error
which contains information about the problem. All preceding “success” return values carry so-called “zero values”: for pointers or interface types, that’snil
; for structs or primitives, that’s the default initial value of the respective type (e.g.,0
for integers). The exact value doesn’t actually matter, because the caller is expected to disregard the “success” return value(s) in case of failure.
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
}
Separating the scopes
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:
- The keyword
handle
- The error variable
- The handler block
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.
Read on in second part, where I explore how this concept can be adopted to the function body.
-
For an overview of existing proposals, the issues in the Go repository might be a good starting point. ↩︎
-
The exact implementation is not important, we only need to care about the signature here. ↩︎
-
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. ↩︎ -
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. ↩︎