home apg(7) colophon feed

Check for Go Errors

There's a lot of discussion about error handling in Go. I'm not part of those conversations, and certainly haven't read all of the arguments out there, but it seems to be the case that something like try() is leading the mindshare.

As a user of Go, I'm not a fan--for many of the same reasons that have been stated in the above linked issue, and beyond. To summarize my uninformed, unqualified, unemperically arrived at opinion:

  1. try() is a built in, but can be used in any expression where a function call can occur. I have nightmares just thinking about the "oh shit, there's a try() here" moments that Go programmers will facepalm on. To clarify, as it can occur in expression context, it can be used to initialize a nested struct, miles deep from anyone caring. I don't see how this won't be abused.
  2. While seemingly backwards compatible, and solving a common complaint, wrapping a returned error value with more context is awkward at best given the requirement to use a defer and named return values, and incomplete at worst, since you can't really wrap an error with two different contexts depending on which thing failed. As an example, in foo(try(bar()), try(baz())) a defer SomeErrorWrapper(&err) has no way to add context that it was bar that returned the error, or was that baz?
  3. It doesn't raise the bar. What I mean by that is the language isn't any better for having try(), just a tad bit more convenient under limited circumstances.

So, I hope this doesn't land in Go.

In my unqualified, uninformed, unemperically studied opinion, there is a construct out there that could be used to fulfill this need, and even raise the bar. Yes, I'm talking about the good ole assert statement that is noticably absent in Go.

What is assert? When assertions are turned on, the assert statement in many languages ensures that the program blows up when a condition is met. This is effectively the same as the following in Go:

// assert(x != 1)
if !(x != 1) {
    panic("Assertion error: x != 1")

But, I don't think Go should adopt assert as the keyword. Instead it should use check. check, like panic would be a built-in function that, by default, pops 2 frames from the stack--its own call frame, and its caller's call frame. This is the same as try() in terms of call stack munging. check doesn't return anything, making it impossible to use anywhere but left justified to the current indentation. Thus, it's easy to scan.

It has an API that is likely controversial:

func check(error, ... string)

But, also:

func check(bool, ... string)

In both cases, the string arguments are optional. When given, they are joined together with a ": " and used to add context. In the case of check(error...) the resulting error is wrapped with it, e.g. errors.Wrap(err, strings.Join(args, ": ")). It looks like this:

func codeUsingCheck() {
    f, err := os.Open(filename)
    defer f.Close()
    // Or... 
    f, err := os.Open(filename)
    check(err, "open")
    defer f.Close()

That gets translated into the if err != nil construct nearly equivalent to try. This, notably, doesn't change f like the try() proposal does, so if os.Open set err, but also provided a value for f it'd be preserved.

In the other variant of check(bool, ...) the behavior depends on the value of the environment variable GOCHECKBEHAVIOR. For those calls, the following Go equivalent is generated by the compiler:

func codeUsingCheck() {
    if os.Getenv("GOCHECKBEHAVIOR") != "" {
        check(condition, ...)

And the check in turn, effectively is:

    if !condition {
        panic("check failed: " + strings.Join(args, ": "))

Thus, if GOCHECKBEHAVIOR is unset, the check serves as documentation but has minimal runtime penalty, in the same way that turning off assertions in languages that support them doesn't matter. They're documentation in that mode, without consequences. But, if GOCHECKBEHAVIOR=panic and the argument to check is false, you get an assertion like behavior--a panic. Of course, this behavior works wonderfully well in testing code, and by default GOCHECKBEHAVIOR is set to "panic" in test code.

Maybe this differentiation by first argument is too magical, but polymorphic builtins are already present in Go, so there's precedence. The behaviors of the two variants are also not that different. In the "first argument is error" case the stack unwinding is 2 frames. In the bool case, it's as many frames as there's no corresponding recover() for.

How does this play out against my earlier complaints about try()?

  1. check() is still a builtin, but can't be used in any expression context. It's limited to being called in the same places that return or panic can be used. This is infinitely better for readability.
  2. It's still backwards compatible, aside from the fact that many codebases may already have a function named check in use. It doesn't use formatting, and therefore does not rely on any other packages (a complaint common for the error wrapping function proposals and try()), save for strings. Join is simple enough that this shouldn't be of concern, however.
  3. The feature serves more than just cosmetic purposes. It provides useful mechanism for performing non-local exits of functions that can't continue based on previous results. As well as general error handling, check(bool... can be used to apply Meyer's "Design by Contract" principles in Go. This improves both testing and documentation--2 things that Go prides itself in having a sane story for.

I won't put this forward as an actual proposal for Go, as I definitely do not want to commit to doing due diligence on the proposal, or fielding loads of feedback. But, this is the direction I hope improved error handling goes. It's not just syntactic sugar, but a backwards compatible way to improve the code we write. That should be the goal here.

- 2019/06/08