Joining errors in Go
Hi! Today I would like to discuss a topic that often comes up in our daily work - error handling and wrapping, which is a critical aspect of developing reliable Go applications for several reasons:
- Context Preservation: Error wrapping allows adding contextual information at each level of the call stack while preserving the original error details
- Flexible Handling: Using
errors.Isanderrors.Asallows checking specific error types at any level of the application, regardless of how deeply they are wrapped. - Clean Architecture: Upper levels of the application can make decisions about error handling with access to the entire chain of events that led to the error.
Let's look at an example: in a web service, a low-level database error can be wrapped at the repository level, then at the service level, and finally at the HTTP handler level, where:
This is especially useful when developing microservices, where proper error handling and logging are critical for diagnosing problems in production.
The standard errors package in Go supports joining multiple errors in addition to the more common wrapping using %w.
I haven't seen this used often in practice; I think most people either refactor code to avoid multiple errors, return a slice of []error, or use uber-go/multierr. Let's look at this!
var (
ErrDatabaseConflict = errors.New("connection refused")
ErrCodeServiceExample = errors.New("cannot get user profile")
ErrCodeHandlerExample = errors.New("internal error")
)
err1 := fmt.Errorf("G-switch failed: %w %w %w", ErrDatabaseConflict, ErrCodeServiceExample, ErrCodeHandlerExample)
// 2009/11/10 23:00:00 G-switch failed: connection refused cannot get user profile internal error
log.Fatal(err1)Let's break down this code in detail:
- First, three error constants (
ErrDatabaseConflict,ErrCodeServiceExample,ErrCodeHandlerExample) are defined usingerrors.New() - Then, a composite error
err1is created usingfmt.Errorf(), which combines all three errors using the%wverb - Using multiple
%win a singlefmt.Errorf()allows creating a chain of wrapped errors - When output through
log.Fatal(), we see all three errors in one message, separated by spaces
This is useful when you need to preserve information about multiple errors that occurred simultaneously and pass them up the call stack as a single error.
The second approach uses the errors.Join function, introduced in Go 1.20. The function takes a variable number of error arguments, discards all nil values, and joins the remaining provided errors. The message is formatted by joining the strings obtained by calling the Error() method of each argument, separated by a newline character.
err2 := errors.Join(
ErrDatabaseConflict,
ErrServiceCodeExample,
ErrHandlerCodeExample,
)
// 2009/11/10 23:00:00 connection refused
// cannot get user profile
// internal error
log.Fatal(err2)How to use them?
At this point, we've looked at two ways Go supports error wrapping: direct wrapping and joined errors.
Both variants ultimately form an error tree. The most common ways to check this tree are the errors.Is and errors.As functions. Both functions examine the tree in depth-first order, unwrapping each node as they go.
func Is(err, target error) bool
func As(err error, target any) boolThe errors.Is function checks the input error tree, looking for a leaf node that matches the target argument, and reports if it finds a match. In our case, it's a leaf node that corresponds to a specific joined error.
ok := errors.Is(err1, ErrHandlerCodeExample)
fmt.Println(ok) // trueOn the other hand, errors.As checks the input error tree, looking for a leaf node that can be assigned to the type of the target argument. Think of it as analogous to json.Unmarshal.
var appErr *AppError
ok = errors.As(err2, &appErr)
fmt.Println(ok) // falseSo, to summarize:
errors.Ischecks if a specific error is part of the error treeerrors.Aschecks if the error tree contains an error that can be assigned to the target type
Interesting Nuance
We can use these two types of error wrapping to form a tree. But let's say we want to examine this tree in more detail in another part of the codebase. The errors.Unwrap function allows us to get the directly wrapped errors.
But there's an interesting nuance here. Let's try calling errors.Unwrap() directly on either of the two joined errors created above.
fmt.Println(errors.Unwrap(err1)) // nil
fmt.Println(errors.Unwrap(err2)) // nilUnexpected, isn't it? Why does this happen and how can we get the original slice of errors and examine it? Well, the nuance here is that these two variants implement different Unwrap methods.
err1 => Unwrap() error
err2 => Unwrap() []errorThe documentation for the errors.Unwrap method clearly states that it only calls the first method and doesn't unwrap errors returned by the Join function. There were several discussions on golang/go about implementing a more direct way to unwrap joined errors, but unfortunately, no consensus was reached. Currently, this can be done using either errors.As or built-in interface assertion to access the second Unwrap implementation.
var joinedErrors interface{ Unwrap() []error }
// We can use errors.As to ensure the alternative Unwrap() implementation is available
if errors.As(err1, &joinedErrors) {
for _, e := range joinedErrors.Unwrap() {
fmt.Println("-", e)
}
}
// Or do it more directly using built-in type assertion
if uw, ok := err2.(interface{ Unwrap() []error }); ok {
for _, e := range uw.Unwrap() {
fmt.Println("~", e)
}
}So, with this extra little step, but using either of these strategies, you'll be able to get the original slice of errors and manually traverse its tree.