Writing constant errors with Go 1.13
In Go, errors are normally matched by value rather than type. It is common for a package to define some of the errors that it may raise so that the user can match against. These errors are often listed as variables, even though they are not intended to be changed.
package ximport (
"fmt"
)// Errors raised by package x.
var (
ErrPermissionDenied = fmt.Errof("permission denied")
ErrInvalidParameters = fmt.Errof("invalid parameters")
)
The reason they are written as variables, is because the Go const
keyword does not allow for any form of runtime evaluated values to be set. Constants can contain character, string, boolean, or numeric values. Maps, structs and slices are in other words a no-go.
Dave Cheney have previously written about how you can write constant errors. It’s a simple but neat solution to how you can declare immutable errors to match against.
Let’s adapt our example to include this solution:
package xvar constError stringfunc (err constError) Error() string {
return string(err)
}// Errors raised by package x.
const (
ErrPermissionDenied = constError("permission denied")
ErrInvalidParameters = constError("invalid parameters")
)
However, what if you wanted to supply the user with more details on what went wrong? E.g. we might want to say which parameters where invalid and why. Let’s write some code that does that:
package x// Foo is guaranteed to return an error.
func Foo(s string) error {
if s != "bar" && s != "baz" {
err := errors.New("s not in [bar baz]")
return fmt.Errof("%s: %v", ErrInvalidParameters, err)
}
return ErrPermissionDenied
}
If we now want to match against ErrInvalidParameters
and ErrPermissionDenied
with Go1.12, we can’t really do so without package-specific knowledge.
err := x.Foo(v)
switch err {
case ErrInvalidParameters: // will never match
// Handle error ...
case ErrPermissionDenied: // will match if raised
// Handle error ...
}
The best work-around for Go 1.12 would likely be to write a helper function to match for the error in package x
. We could then call this helper function when checking the errors from Foo.
err := x.Foo(v)
switch {
case x.IsErrInvalidParameters(err): // Package specific handling
// Handle error ...
case err == ErrPermissionDenied:
// Handle error ...
}
This isn’t a bas solution, but it looks inconsistent, and we would need to read the package documentation carefully to determine how to check for errors for this package in particular.
Enter Go 1.13
To allow for easier and standardized error-matching across packages and package authors, Go 1.13 have added functions for error wrapping, unwrapping and matching to the standard library. The latter allows us to write our own logic for what an error consider as an equal value, while leaving the way users check for errors more standardized.
Let’s put this into use by updating our example:
package ximport (
"fmt"
"strings"
)// Errors raised by package x.
var (
ErrPermissionDenied = wrapError{msg:"permission denied"}
ErrInvalidParameters = wrapError{msg:"invalid parameters"}
)type wrapError struct {
err Error
msg string
}func (err wrapError) Error() string {
if err.err != nil {
return fmt.Sprintf("%s: %v", err.msg, err.err)
}
return err.msg
)}func(err wrapError) wrap(inner error) error {
return wrapError{msg: err.msg, err: inner}
}func (err wrapError) Unwrap() error {
return err.err
}func (err wrapError) Is(target error) bool {
ts := target.Error()
return ts == err.msg || strings.HasPrefix(ts, err.msg + ": ")
}
We can now update our Foo
function to utilizing the declared wrap
helper when raising errors:
package xfunc Foo(s string) error {
if s != "bar" && s != "baz" {
err := errors.New("s not in [bar baz]")
return ErrInvalidParams.wrap(err)
}
return ErrPermissionDenied
}
Our matching function can use the new errors.Is
function from the standard library to check for errors in a standardized way:
err := x.Foo(v)
switch {
case errors.Is(err, ErrInvalidParameters): // will match if raised
case errors.Is(err, ErrPermissionDenied): // will match if raised
}
Sadly, our error declarations are no loner constant, which means they can be tampered with. Not that anyone are likely to, but it would be a more sound design if we could get our constant errors back.
Equal values, different types
One interesting detail about matching errors by value though an Is
method, is that the errors don’t need to be of the same type to match. We can utilize this to once again let package x
declare constant errors as matching predicates.
Let’s update our example with a constError
type:
package ximport (
"fmt"
"strings"
)// Errors raised by package x.
var (
ErrPermissionDenied = constError("permission denied")
ErrInvalidParameters = constError("invalid parameters")
)type constError stringfunc (err constError) Error() string {
return string(err)
}func (err constError) Is(target error) bool {
ts := target.Error()
es := string(err)
return ts == es || strings.HasPrefix(ts, es+": ")
}func (err constError) wrap(inner error) error {
return wrapError{msg: string(err), err: inner}
}type wrapError struct {
err error
msg string
}func (err wrapError) Error() string {
if err.err != nil {
return fmt.Sprintf("%s: %v", err.msg, err.err)
}
return err.msg
}func (err wrapError) Unwrap() error {
return err.err
}func (err wrapError) Is(target error) bool {
return constError(err.msg).Is(target)
}
The usage remain exactly the same as in the previous examples. To achieve this, the wrap
method have be moved to the constError
type. The Is
method is replicated across both types so that either a constError
or a wrapError
can be raised by the package and still match against the x package listed error predicates.
Conclusion
In this article we have showed you how to match against an error predicate defined as a static string, but you can define a constant error type from any of the Go primitive types that you desire.
Even with constant error predicates, your package can still return errors that hold the right information. This is achieved by allowing two (or more) different error types to be considered equal based on values.