Error Inspection — Draft Design

Jonathan Amsterdam
Damien Neil
August 27, 2018

Abstract

We present a draft design that adds support for programmatic error handling to the standard errors package. The design adds an interface to standardize the common practice of wrapping errors. It then adds a pair of helper functions in package errors, one for matching sentinel errors and one for matching errors by type.

For more context, see the error values problem overview.

Background

Go promotes the idea that errors are values and can be handled via ordinary programming. One part of handling errors is extracting information from them, so that the program can make decisions and take action. (The control-flow aspects of error handling are a separate topic, as is formatting errors for people to read.)

Go programmers have two main techniques for providing information in errors. If the intent is only to describe a unique condition with no additional data, a variable of type error suffices, like this one from the io package.

var ErrUnexpectedEOF = errors.New("unexpected EOF")

Programs can act on such sentinel errors by a simple comparison:

if err == io.ErrUnexpectedEOF { ... }

To provide more information, the programmer can define a new type that implements the error interface. For example, os.PathError is a struct that includes a pathname. Programs can extract information from these errors by using type assertions:

if pe, ok := err.(*os.PathError); ok { ... pe.Path ... }

Although a great deal of successful Go software has been written over the past decade with these two techniques, their weakness is the inability to handle the addition of new information to existing errors. The Go standard library offers only one tool for this, fmt.Errorf, which can be used to add textual information to an error:

if err != nil {
	return fmt.Errorf("loading config: %v", err)
}

But this reduces the underlying error to a string, easily read by people but not by programs.

The natural way to add information while preserving the underlying error is to wrap it in another error. The standard library already does this; for example, os.PathError has an Err field that contains the underlying error. A variety of packages outside the standard library generalize this idea, providing functions to wrap errors while adding information. (See the references below for a partial list.) We expect that wrapping will become more common if Go adopts the suggested new error-handling control flow features to make it more convenient.

Wrapping an error preserves its information, but at a cost. If a sentinel error is wrapped, then a program cannot check for the sentinel by a simple equality comparison. And if an error of some type T is wrapped (presumably in an error of a different type), then type-asserting the result to T will fail. If we encourage wrapping, we must also support alternatives to the two main techniques that a program can use to act on errors, equality checks and type assertions.

Goals

Our goal is to provide a common framework so that programs can treat errors from different packages uniformly. We do not wish to replace the existing error-wrapping packages. We do want to make it easier and less error-prone for programs to act on errors, regardless of which package originated the error or how it was augmented on the way back to the caller. And of course we want to preserve the correctness of existing code and the ability for any package to declare a type that is an error.

Our design focuses on retrieving information from errors. We don’t want to constrain how errors are constructed or wrapped, nor must we in order to achieve our goal of simple and uniform error handling by programs.

Design

The Unwrap Method

The first part of the design is to add a standard, optional interface implemented by errors that wrap other errors:

package errors

// A Wrapper is an error implementation
// wrapping context around another error.
type Wrapper interface {
	// Unwrap returns the next error in the error chain.
	// If there is no next error, Unwrap returns nil.
	Unwrap() error
}

Programs can inspect the chain of wrapped errors by using a type assertion to check for the Unwrap method and then calling it.

The design does not add Unwrap to the error interface itself: not all errors wrap another error, and we cannot invalidate existing error implementations.

The Is and As Functions

Wrapping errors breaks the two common patterns for acting on errors, equality comparison and type assertion. To reestablish those operations, the second part of the design adds two new functions: errors.Is, which searches the error chain for a specific error value, and errors.As, which searches the chain for a specific type of error.

The errors.Is function is used instead of a direct equality check:

// instead of err == io.ErrUnexpectedEOF
if errors.Is(err, io.ErrUnexpectedEOF) { ... }

It follows the wrapping chain, looking for a target error:

func Is(err, target error) bool {
	for {
		if err == target {
			return true
		}
		wrapper, ok := err.(Wrapper)
		if !ok {
			return false
		}
		err = wrapper.Unwrap()
		if err == nil {
			return false
		}
	}
}

The errors.As function is used instead of a type assertion:

// instead of pe, ok := err.(*os.PathError)
if pe, ok := errors.As(*os.PathError)(err); ok { ... pe.Path ... }

Here we are assuming the use of the contracts draft design to make errors.As explicitly polymorphic:

func As(type E)(err error) (e E, ok bool) {
	for {
		if e, ok := err.(E); ok {
			return e, true
		}
		wrapper, ok := err.(Wrapper)
		if !ok {
			return e, false
		}
		err = wrapper.Unwrap()
		if err == nil {
			return e, false
		}
	}
}

If Go 2 does not choose to adopt polymorphism or if we need a function to use in the interim, we could write a temporary helper:

// instead of pe, ok := err.(*os.PathError)
var pe *os.PathError
if errors.AsValue(&pe, err) { ... pe.Path ... }

It would be easy to mechanically convert this code to the polymorphic errors.As.

Discussion

The most important constraint on the design is that no existing code should break. We intend that all the existing code in the standard library will continue to return unwrapped errors, so that equality and type assertion will behave exactly as before.

Replacing equality checks with errors.Is and type assertions with errors.As will not change the meaning of existing programs that do not wrap errors, and it will future-proof programs against wrapping, so programmers can start using these two functions as soon as they are available.

We emphasize that the goal of these functions, and the errors.Wrapper interface in particular, is to support programs, not people. With that in mind, we offer two guidelines:

  1. If your error type’s only purpose is to wrap other errors with additional diagnostic information, like text strings and code location, then don’t export it. That way, callers of As outside your package won’t be able to retrieve it. However, you should provide an Unwrap method that returns the wrapped error, so that Is and As can walk past your annotations to the actionable errors that may lie underneath.

  2. If you want programs to act on your error type but not any errors you’ve wrapped, then export your type and do not implement Unwrap. You can still expose the information of underlying errors to people by implementing the Formatter interface described in the error printing draft design.

As an example of the second guideline, consider a configuration package that happens to read JSON files using the encoding/json package. Malformed JSON files will result in a json.SyntaxError. The package defines its own error type, ConfigurationError, to wrap underlying errors. If ConfigurationError provides an Unwrap method, then callers of As will be able to discover the json.SyntaxError underneath. If the use of a JSON is an implementation detail that the package wishes to hide, ConfigurationError should still implement Formatter, to allow multi-line formatting including the JSON error, but it should not implement Unwrap, to hide the use of JSON from programmatic inspection.

We recognize that there are situations that Is and As don’t handle well. Sometimes callers want to perform multiple checks against the same error, like comparing against more than one sentinel value. Although these can be handled by multiple calls to Is or As, each call walks the chain separately, which could be wasteful. Sometimes, a package will provide a function to retrieve information from an unexported error type, as in this old version of gRPC's status.Code function. Is and As cannot help here at all. For cases like these, programs can traverse the error chain directly.

Alternative Design Choices

Some error packages intend for programs to act on a single error (the “Cause”) extracted from the chain of wrapped errors. We feel that a single error is too limited a view into the error chain. More than one error might be worth examining. The errors.As function can select any error from the chain; two calls with different types can return two different errors. For instance, a program could both ask whether an error is a PathError and also ask whether it is a permission error.

We chose Unwrap instead of Cause as the name for the unwrapping method because different existing packages disagree on the meaning of Cause. A new method will allow existing packages to converge. Also, we’ve noticed that having both a Cause function and a Cause method that do different things tends to confuse people.

We considered allowing errors to implement optional Is and As methods to allow overriding the default checks in errors.Is and errors.As. We omitted them from the draft design for simplicity. For the same reason, we decided against a design that provided a tree of underlying errors, despite its use in one prominent error package (github.com/hashicorp/errwrap). We also decided against explicit error hierarchies, as in https://github.com/spacemonkeygo/errors. The errors.As function’s ability to retrieve errors of more than one type from the chain provides similar functionality: if you want every InvalidRowKey error to be a DatabaseError, include both in the chain.

References

We were influenced by several of the existing error-handling packages, notably:

Some of these package would only need to add an Unwrap method to their wrapping error types to be compatible with this design.

We also want to acknowledge Go proposals similar to ours: