Writing constant errors with Go 1.13

Sindre Myren
Sep 9, 2019 · 4 min read

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.

Sindre Myren

Written by

Backend developer at Searis AS, and occasional tech blogger.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade