Dealing With Errors in Go
There’s a lot to like about Go, and I’ll happily admit being a huge fan of the language. But it will be dishonest of me not to acknowledge that some aspects of programming in Go results in more code than the equivalent in another language. My guess is that the best example of this is how Go programs deal with errors.
For those unfamiliar with how errors work in Go, the short version is that they are just like any other type that you deal with — like strings and integers — and they have no special control structure to handle them in any specific way. This means that, unlike languages that have exceptions, there is nothing like try/catch blocks; you are left with the standard control statements that are available.
The result; you will fall into the practice of having a lot of code that looks like this:
func doStuff() error {
firstResult, err := doFirstThing()
if err != nil {
return err
}
secondResult, err := doSecondThing(firstResult)
if err != nil {
return err
}
lastResult, err := doOneLastThing(secondResult)
if err != nil {
return err
}
return processResult(lastResult)
}
This is not bad in-and-of-itself, but it does have some shortcomings over the equivalent in languages that use exceptions. The use of an if block after every call to a “do function”1 adds a bit of noise making it harder to separate the happy path from the error handling. And although the code is technically correct — whereby the errors are being handled appropriately — you may come to wonder whether this could be done in a nicer way.
This post explores some alternatives for dealing with Go errors. It is by no means exhaustive; it’s just a few patterns I’ve found works for me. It is also by no means suggestive that you even should use one of the alternatives. Each use case is different; and one alternative might be a better solution in a particular case, but dramatically worse in another. Everything in coding, much like life itself, is a tradeoff, and I would suggest being mindful of the potential costs of adopting any one of these options alongside the benefits they may bring.
With that said, let’s look at some alternatives.
Option 0: Keep It As It Is
This is probably not an option that you’d like to hear, but it is one worth considering if the function is small enough, and you don’t have the ability to change the functions you are calling. As ugly as the code looks, it does have some advantages:
- It makes the erroneous path crystal clear: it indicates that any one of these operations can fail with an error, and that it is the job of the function to handle it in some way, even if it is simply returning it as it’s error.
- It makes it reasonably easy to move things around or change how the error is to be handled: if
doSecondThing
returns an error that no longer blocks the call todoOneLastThing
, you only need to adjust one if statements. This is harder to do in any generic solution you may adapt. - It provides an incentive to keep functions small: for example, if there is a need to expand the number of operations from 4 to 30, and each operation returns an error that needs to be handled, then that would impart a large enough pressure to refactor the code, and break it up across multiple functions.
So I’d recommend considering this as a viable option first, if only briefly. Spending effort on a solution that may look neater can actually have the opposite effect of making the code less understandable, while also making it harder to maintain.
Option 1: Don’t Handle The Error
No, don’t go. Let me explain.
This is not a viable option if you are writing code intended for a production setting. Arguably, if something can fail in some way, you should handle it. But I’m listing this option here as it is an alternative to the if statements above if one of the following scenarios is true:
- The functions don’t return errors: they may be required to implement a type that does, but if it is clear within the public documentation that they never do, then there is no real need to handle the error. Some care will need to be observed in this case though: public documentation is not the the same as an API, and depending on who’s maintaining these functions, it is very possible that, down the line, the implementor takes advantage of the error return type, and starts returning them.
- The error can be safely ignored: this might be test code, or code that is written once, then thrown away. In these case, it may not be worth your while adding support for error handling if it provides no real value.
Adopting this option may make the function look like the following. It may not be possible to simplify further unless you can actually change the return type of the “do functions”; in which case, there are no errors that need handling.
func doStuff() error {
firstResult, _ := doFirstThing()
secondResult, _ := doSecondThing(firstResult)
lastResult, _ := doOneLastThing(secondResult)
// You can probably ignore this error as well, but it's simpler to just return it
return processResult(lastResult)
}
Option 2: Use Panic
The second option is to use panic to throw the error, and handle it in a single defer and recover handler.
A first draft of this solution may look something like the following:
func doStuff() (err error) {
defer func() {
if e, isErr := recover().(error); isErr {
err = e
} else {
panic(e)
}
}()
firstResult, err := doFirstThing()
if err != nil {
panic(err)
}
secondResult, err := doSecondThing(firstResult)
if err != nil {
panic(err)
}
lastResult, err := doOneLastThing(secondResult)
if err != nil {
panic(err)
}
return processResult(lastResult)
}
which is not much better than what we had to begin with. However, if we can modify the “do functions” themselves,
we can replace them with versions that panic instead of return an error. These new “must do functions” — so named as
the must
prefix is used to indicate that they will panic if things go wrong — can bring out the happy path quite clearly:
func doStuff() (err error) {
defer func() {
if e, isErr := recover().(error); isErr {
err = e
} else {
panic(e)
}
}()
firstResult := mustDoFirstThing()
secondResult := mustDoSecondThing(firstResult)
lastResult := mustDoOneLastThing(secondResult)
mustProcessResult(lastResult)
return
}
This can be improved further if the “do functions” actually return values of the same type. For that, we don’t actually need to replace the function. Instead, we can build a function that simply takes the result and error, and either return the result or panic depending on whether an error was returned2:
type doResult struct { ... }
func must(res doResult, err error) doResult {
if err != nil {
panic(err)
}
return doResult
}
func doStuff() (err error) {
defer func() {
if e, isErr := recover().(error); isErr {
err = e
} else {
panic(e)
}
}()
firstResult := must(doFirstThing())
secondResult := must(doSecondThing(firstResult))
lastResult := must(doOneLastThing(secondResult))
if err := processResult(lastResult); err != nil {
panic(err)
}
return
}
This will work, but I’d argue it’s not the best use of panic. Go panics should be reserved for unexpected errors, instead of as a poor substitute for exceptions. Instead, I suggest the following option, which provides a nicer API while maintaining the principal of errors as values.
Option 3: Encapsulate The Error Handling Using A Dedicated Type
This is probably my preferred option of the three. Basically, the idea to track the state of the error using a dedicated type, and wrapping the functions that return errors within methods that don’t. The methods will be defined on the type that is maintaining the error state, and each one will check whether an error has been raised before invoking the wrapped function. Finally, the struct will offer a way to get the error, so that it can be logged or returned.
The way this looks in code would be the following:
type DoOperations struct {
err error
}
func (d *doOperations) DoFirstThing() (res doResult) {
if d.err != nil {
return doResult{}
}
res, d.err = doFirstThing()
return
}
func (d *doOperations) DoSecondThing(op doResult) (res doResult) {
if d.err != nil {
return doResult{}
}
res, d.err = doSecondThing(op)
return
}
// Likewise for doOneLastThing and processResult
func (d *doOperations) Err() error {
return d.err
}
Then, the our new function will simply become:
func doStuff() error {
ops := new(doOperations)
firstResult := ops.DoFirstThing()
secondResult := ops.DoSecondThing(firstResult)
lastResult := ops.DoOneLastThing(secondResult)
ops.ProcessResult(lastResult)
return ops.Err()
}
This gives us the ability to write a function with the happy-path clearly shown while still maintaining best practices around error handling.
As nice as this is, the one downside is that this type is specific to the set of operations that we need to deal with. If this is the only function that needs to perform these operations, then the additional maintenance overhead might not offset the nicer API that this gives you. But if you find yourself writing out this sequence of operations in a variety of different ways, it may be worth your while to consider this approach.
To Be Continued
It’s very likely that this topic may be revisited as the Go language evolves. With the design of type parameters imminent, and discussions around adding language features to make error handling nicer in general, additional options may become possible down the line. But for now, these are the options that seem like current viable alternatives to the function-call-then-if-block pattern seen in a lot of Go code.
-
For brevity, I’ll be referring to the functions
doFirstThing
,doSecondThing
anddoOneLastThing
collectively as the “do functions”. ↩︎ -
An example of this in the standard library is the template.Must() function. ↩︎