"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 error
s), 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
...
}
}
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 {
...
}
}
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
}
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")
}
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 error
s, 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.
Top comments (4)
How's the solution about declaring the variables solves the shadowing in that example? Don't you need to also declare de second error with a different name?
Yeah you could do that. The example I gave might not be great. I think it all comes down to how you handle your errors and when you check them.
Interesting reading, thanks.
I'm not sure about the consequences, I may have to experiment
Why don't you use early returns to fix your problem?