Joining errors in Go
I recently realized that the stdlib errors
package in Go supports joining
multiple errors in addition to the more common %w
wrapping.
I haven’t really seen this used much in the wild; I think most people either refactor to avoid multiple errors, return an []error slice or use uber-go/multierr. Let’s go have a look!
I started drafting a longer version of this post, but it almost blew up to become a fully-fledged “Guide to errors in Go”. While that bigger post may be coming in the future, I think this one is useful enough to share on its own.
Joining errors
You can join multiple errors in two ways. They have slightly different semantics under the hood (look at the Appendix section if you care), but they both work in a similar way.
The first one is by using multiple %w
verbs on the same error.
var (
ErrRelayOrientation = errors.New("bad relay orientation")
ErrCosmicRayBitflip = errors.New("cosmic ray bitflip")
ErrStickyPlunger = errors.New("sticky sensor plunger")
)
err1 := fmt.Errorf("G-switch failed: %w %w %w", ErrRelayOrientation, ErrCosmicRayBitflip, ErrStickyPlunger)
// 2009/11/10 23:00:00 G-switch failed: bad relay orientation cosmic ray bitflip sticky sensor plunger
log.Fatal(err1)
The second one uses the errors.Join
function introduced in Go 1.20.
The function takes in a variadic error argument, discards any nil values, and
joins the rest of the provided errors. The message is formatted by joining the
strings obtained by calling each argument’s Error() method, separated by a
newline.
err2 := errors.Join(
ErrRelayOrientation,
ErrCosmicRayBitflip,
ErrStickyPlunger,
)
// 2009/11/10 23:00:00 bad relay orientation
// cosmic ray bitflip
// sticky sensor plunger
log.Fatal(err2)
How to use them?
By now, we’ve seen the two ways that Go supports error wrapping, direct wrapping and joined errors.
Both variants ultimately form a tree of errors. The most common ways to
inspect that tree are the errors.Is
and errors.As
functions. Both of these
examine the tree in a pre-order, depth-first traversal by successively
unwrapping every node found.
func Is(err, target error) bool
func As(err error, target any) bool
The errors.Is
function examines the input error’s tree, looking for a leaf
that matches the target argument and reports if it finds a match. In our case,
this can look for a leaf node that matches a specific joined error.
ok := errors.Is(err1, ErrStickyPlunger)
fmt.Println(ok) // true
On the other hand, errors.As
examines the input error’s tree, looking for a
leaf that can be assigned to the type of the target argument. Think of it as
an analog to json.Unmarshal.
var engineErr *EngineError
ok = errors.As(err2, &engineErr)
fmt.Println(ok) // false
So, to summarize:
errors.Is
checks if a specific error is part of the error treeerrors.As
checks if the error tree contains an error that can be assigned to a target type
The catch
So far so good! We can use these two types of error wrapping to form a tree.
But let’s say we wanted to inspect that tree in a more manual way on another
part of the codebase. The errors.Unwrap
function allows you to get direct
wrapped errors.
But there’s a slight complication here. Let’s try to call errors.Unwrap() directly on any of the two joined errors created above.
fmt.Println(errors.Unwrap(err1)) // nil
fmt.Println(errors.Unwrap(err2)) // nil
So, why nil
?! What’s going on? How can I get the original errors slice and
inspect it? Turns out, that the two ‘varieties’ implement a different Unwrap
method.
Unwrap() error
Unwrap() []error
The documentation of errors.Unwrap
method clearly states that it only calls
the first one and does not unwrap errors returned by Join. There have been
multiple
discussions on golang/go about
allowing a more straightforward way to unwrap joined errors, but there has been
no consensus.
The way to achieve it right now is to either use errors.As
or an inline
interface cast to get access to the second Unwrap implementation.
var joinedErrors interface{ Unwrap() []error }
// You can use errors.As to make sure that the alternate Unwrap() implementation is available
if errors.As(err1, &joinedErrors) {
for _, e := range joinedErrors.Unwrap() {
fmt.Println("-", e)
}
}
// Or do it more directly with an inline cast
if uw, ok := err2.(interface{ Unwrap() []error }); ok {
for _, e := range uw.Unwrap() {
fmt.Println("~", e)
}
}
So, it’s an extra little step, but with either of these techniques you’ll be able to retrieve the original slice of errors and manually traverse the error tree. My motivation for this was following along Crafting Interpreters; when implementing the language’s lexer/scanner, I wanted to keep gather all encountered errors and report them in one go.
Outro
And that’s all for today! If you have any comments, remarks or ideas, feel free to reach out to me on X/Twitter or Mastodon!
Oh, and you can play around with the code samples in this post on the Go Playground.
Until next time, bye!
Appendix 1
In the example that we saw, the underlying types were as follows for these three errors.
So, while multiple %w
verbs give out a slice of errors, directly, the errors.Join function wraps them in a joinError
struct.
type joinError struct {
errs []error
}
var (
ErrRelayOrientation = errors.New("bad relay orientation")
ErrCosmicRayBitflip = errors.New("cosmic ray bitflip")
ErrStickyPlunger = errors.New("sticky sensor plunger")
)
err1 := fmt.Errorf("G-switch failed: %w\n%w\n%w", ErrRelayOrientation, ErrCosmicRayBitflip, ErrStickyPlunger)
err2 := fmt.Errorf("G-switch failed: %w", errors.Join(
ErrRelayOrientation,
ErrCosmicRayBitflip,
ErrStickyPlunger,
))
err3 := errors.Join(
ErrRelayOrientation,
ErrCosmicRayBitflip,
ErrStickyPlunger,
)
// &fmt.wrapErrors{msg:"bad relay orientation\ncosmic ray bitflip\nsticky sensor plunger", errs:[]error{(*errors.errorString)(0xc00009c050), (*errors.errorString)(0xc00009c060), (*errors.errorString)(0xc00009c070)}}
// &fmt.wrapError{msg:"G-switch failed: bad relay orientation\ncosmic ray bitflip\nsticky sensor plunger", err:(*errors.joinError)(0xc0000be000)}
// &errors.joinError{errs:[]error{(*errors.errorString)(0xc0000140a0), (*errors.errorString)(0xc0000140b0), (*errors.errorString)(0xc0000140c0)}}