Published at 14.10.2022
We finished the previous chapter of this series with returning Sentinel error values, and noticed that we may want to provide additional information to the user. We learned that sentinel errors are values of type error you can expose within your go project by making them public variables. This allows users of your project to compare these values with the errors you return from your projects' functions, to handle different kinds of errors gracefully.
We then ran into a problem because we wanted to enrich the errors with some additional information for the user, but we wanted to keep the ability to handle different types of errors gracefully.
An example of this could be, to keep with the theme of a configuration provider, showing the user where a syntax error in his configuration has occurred.
Let's assume your config provider expects the configuration to follow a certain syntax. Using the Sentinel error approach we can create an ErrSyntax sentinel. If we return this to the user, he will know that a Syntax error has occurred, but will gain no further insight.
Let's add the new Sentinel error to our configprovider package.
1var ( 2 //… 3 ErrSyntax = errors.New(“syntax error”) 4)
What if we also want to communicate at which line the error has occurred? Naively we could start by creating and returning a new error.
errors.New(“syntax error in line ” + line)
But now we can no longer compare the error to a known set of variables, and handle different error cases accordingly.
For this use case the Go standard library provides a couple of useful helper methods for working with errors.
fmt.Errorf can be used to create an error from a format string. It adds another special formatting verb "%w," which can be used to wrap an error. We can call errors.Unwrap() on a wrapped error, and get back the original error we passed into %w. This means the returned error does not only include the error message of the error passed as an argument but that it can also be handled as needed.
This allows us to add the additional information to our error for the user like so:
1err := fmt.Errorf(“%w: line %d”, ErrSyntax, line) 2fmt.Println(err) // “syntax error: line 11” 3 4errors.Unwrap(err) // Returns ErrSyntax. 5errors.Is(err, ErrSyntax) // true. 6
Wrapping an error with the %w verb will create a new error that contains the old error value. This means the error is no longer the same exact value, so we can no longer compare it using the == operator. However, the go errors library introduces the utility method errors.Is which we can use to compare errors. errors.Is will try to unwrap an error if it detects that it is a wrapped error.
1err == configProvider.ErrSyntax // false, however 2errors.Is(configprovider.ErrSyntax, err) // true
Which means we need to use errors.Is to compare the new errors.
Now we know how to add additional information to a sentinel error value, and we are still able to differentiate between different types of errors to handle them gracefully.
What if the message we want to include is not something determined by us, but an error from another package? Perhaps the syntax is parsed in a library we import.
We may want to use our own Sentinel error value to indicate the general type of error the function has run into, but we do not want to parse (and re-format) the error returned by the library we use. However the error message from the library may include useful information to the user, or for debugging purposes, so we do not want to lose it.
We could also use fmt.Errorf to create an error that wraps the sentinel error defined in the interface, and then add the more detailed implementation-specific error message as a string. To format the underlying error we use %v to format the error message as a string. We could also use the "%+v" verb to format it including field names, or using the verb "%#v" to format it in go object notation.
1fmt.Errorf(“%w: %v”, configprovider.ErrSyntax, err)
Then we can still use errors.Is to check for the different error sentinels when handling the error.
1errors.Is(fmt.Errorf(“%w: %v”, configprovider.ErrSyntax, err), 2configprovider.ErrSyntax) // true
This approach is fairly solid and may suffice for your needs, but there
are still some downsides.
Perhaps you want to display the error message to the user. But it now includes the string of the implementation-specific error, which contains information you don't want to show.
You could use errors.Unwrap to get the sentinel error. If the error originated deep within a call stack, it might have been wrapped again. You could then try to recursively unwrap the error until you no longer can. However, your sentinel error may be a wrapped error as well. It could still contain additional information you want to show to your user, in which case this won't work.
If you don't plan on displaying your errors to the user, you can still use this approach to great success.
Suppose you choose to only use this approach on public-facing functions. In that case, you can enforce that calling unwrap will always return the sentinel error. You can also use this approach, but you have to keep in mind the implicit contract your function has to fulfill.
We learned how to add additional information to sentinel error values by wrapping the error using fmt.Errorf and the "%w" formatting verb, and how to compare them to a known set of errors using the errors.Is function. We learned that this can be used to enrich the error messages for displaying to a user, or for debugging purposes.
When we started to increase the complexity of our hypothetical application, we noticed that we may need to include errors that originated from other APIs or libraries into our error message, but we still want to keep the resulting error messages for the user. This can also be achieved using fmt.Errorf, but may become messy with multiple layers of abstraction. In the next blog post we will describe how golang errors work under the hood, and how you can implement your own error types for maximum flexibility.
Check out the full series here: Error Handling in Go