Golang 'error': Everything you know is wrong

wspowell

Wesley Powell

Posted on August 4, 2022

Golang 'error': Everything you know is wrong

"errors as linked lists"

The Attempted Concept

The concept of errors as linked lists seems neat at first. You can define your own error type, customize how it prints, and add extra information to it. Once instantiated, you can simply pass it up to the caller as a normal old error. Callers of your function can then check to see if your error Is() another. Cool! Further even, you can add extra info to an error that you wrap and bubble up. Awesome! error is so flexible and powerful!

error Chain Of Pain

You start setting up error handling in your first golang application and you find that you start running into problems. You find that you cannot both return an error that Is() both your custom error and the error you just received. "That's fine", you think to yourself, "I should not have the caller rely on my implementation details anyway". So you return your API's own error and move on.

You then start working with some HTTP helper library that returns an error if a request fails. Suddenly, you realize that you also need to know if that error Is() a context.DeadlineExceeded from the request timing out. "That's fine", you think again, "I know how to handle that!". That is when you realize that the DeadlineExceeded error most likely originates from deep within that call stack and you would be relying on their implementation details. Even worse, you realize, "What if the library I am using does not bubble up that error, just as I did earlier? After all, that library could be catching it and returning their own errors too, or worse, that library calls yet another one that does". Suddenly, you realize that everything you knew and trusted about golang errors is wrong. You can no longer trust the error chain to provide reliable consistent results from Is().

error Format Catastrophe

I would suggest reading this blog about the catastrophe of golang error printing. This blog discusses yet another failure of the golang error pattern, that if you have a chain of errors and if any of them do not implement Unwrap() error (an optional interface implementation for errors), then your whole chain breaks down. It also goes into detail about the issue of calling Format() on the error chain.

error Handling Problems

One issue that many golang devs might not realize is that errors are constantly being shadowed. This is not usually a problem because once an error is encountered, generally, the function returns. Consider the following:

if fooResult, err := foo(); err != nil {
    if barResult, err := bar(); err != nil { // err shadows previous err
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

The err returned by bar() shadows the err returned by foo(). This is probably not going to cause any problems but you do end up with two instances of err in the same function. Anytime you have shadowed variables it is a code smell. Unfortunately, that means all of golang error handling smells.

Side note: golang gets away with this because it makes an exception on the := operator that if at least one variable is newly declared, then it is fine. That is why you can redeclare err constantly. It just bends the rules a bit to fit the pattern, but causes variable shadowing in the process.

The only way to solve this is by not shadowing those variables, but the solution is not pretty to read:

var fooResult struct{}
var err error
if fooResult, err = foo(); err != nil {
    var barResult struct{}
    if barResult, err = bar(); err != nil {
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

So the solution is to declare your variables before assignment. Following that train of thought, if you think this issue is solved with named returns, think again. Let's take this example:

func task() (err error) {
    if err = foo(); err != nil {
        if err := bar(); err != nil {
            ...
        }
    }
    return
}
Enter fullscreen mode Exit fullscreen mode

Whoops! You just typo-ed that := on bar(). The compiler says,👍. The function works. But only foo() errors will ever be returned. This is a logic bug, plain and simple. It is easy to fix, but could be difficult to find.

The "Solution"

How do you solve these problems? Short answer: you don't. At least, not without changing the core golang pattern and probably having a few debates with your coworkers about best practices. This is a problem the language itself must address.

Experimental Alternative

Frustrations with error has led me to the propose following changes (that you can try right now!):

  • Abandon error and never look back
    • errors package becomes dead code as a result
    • fmt directive "%w" can go away too
  • Make a type Error struct {}
  • Make errors values, not references
  • type error Error, for good measure
    • Yes, you can do that. Get that thing outta here!
  • Make a type Result[T any] struct {}

Now you can live in golang free of the burdens of the built in error interface. For example:

func task() Result[int] {
    fooResult := foo()
    if !fooResult.IsOk() {
        return fooResult
    }

    barResult := bar()
    if !barResult.IsOk() {
        return Err[int](barResult.Error())
    }

    return Ok(1)
}

func foo() Result[int] {
    return Ok(2)
}

func bar() Result[float32] {
    return Err[float32]("whoops")
}
Enter fullscreen mode Exit fullscreen mode

Goplay

This is a horrible example, but it demonstrates that you can easily change error handling for the better. Technically, this will impact consumers of this package who use golang error, but the issues this will cause are same that exist already. Broken Unwrap() error chains. Poor formatting standards. Leaking implementation details. You get the idea.

If you noticed, this proposed pattern solves the issues noted in the above section. Errors are values (no wrapping involved), so no more weird chaining issues. Error printing is reliable since there is a defined error struct. Each function or package can define explicit Errors as part of its API that can be checked for by equivalence, not by Is() chain detection. Result is a single value, so no more shadowed variables. If you want to add more info to your errors, you could embed Error in your own type ApiError struct {}.

A surprise benefit of this new pattern is that your errors no longer automatically escape to the heap (hint: error is an interface). That's right. Not only is this pattern easier to use and solves the issues of standard golang errors, but it is more performant.

More work obviously needs to be put into this before it can be useful in an actual project, but this is definitely a great start.

💖 💪 🙅 🚩
wspowell
Wesley Powell

Posted on August 4, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related