fail err
Musings about error handling syntax in Go (2/2)
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)
}
- On the call side, the scopes are accurately separated by means of a
try
/handle
block - Within the function, the exit status are expressed clearly via the
return
andfail
keywords - The function signature distinguishes between success and failure, and reflects how both outcomes are mutually exclusive
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:
- How would you write a function that produces an
error
object as “success” value? (Think: a factory function for error objects.) The function would legitimately return anerror
object, but it’s not exactly failing. While it could easily be allowed for anerror
value to appear on the “success side”, this might be a source of subtle bugs. - The
error
type is aninterface
, so its zero value isnil
. That would allow you to writefail nil
, which wouldn’t really make sense, and would force the caller to add defensive checks. Unfortunately, the compiler wouldn’t be able prevent that scenario. - For some scenarios, it might be sufficient to just return a simple
bool
to indicate success or failure, rather than a full-blownerror
object. Would that be compatible with the new syntax in a non-ambiguous way, or would people effectively be forced into using the new pattern?
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.)