Error handling in Go

One of Rob Pike's famous Go Proverbs is that "Errors are values". This concept is deeply embedded in the go language and is seen in countless lines of source code; from multiple return values to if err != nil constructs. While treating errors like values provides you with error-handling flexibility that can't be found in most modern languages, sometimes you just need to check the type of an error and handle it as you would in except or Catch clauses of languages like Python or Java.

The errors package

One of the most common uses of the errors package is errors.New, which creates a new error object from a string. But there are some other useful utilities in this package that can help to improve code flow and readability when inspecting errors.

errors.Is

The errors package provides two incredibly useful functions for checking error value types and casting them as more complex error types that allow users to access additional error metadata. The first is errors.Is(err, target error) bool

For example, we can define a custom error like so. This error is designed to be returned when a config file is invalid.

var ErrInvalidConfig = errors.New("Invalid Config!")

Then, when reading a config file (in this case with an already-initialized configReader), we can check the error value against our custom error to differentiate between an invalid config and something like an IO or OS-level error.

if _, err := configReader.Read("/path/to/config.conf"); err != nil {
    if errors.Is(err, ErrInvalidConfig) {
        println("Invalid Config!  Please check your config and try again")
    } else {
        println(err)
    }
}

errors.As

We can even go a step further and use errors.As to cast the error value into an InvalidConfigError struct to obtain additional metadata, such as the path of the invalid config file. Here is the definition for an InvalidConfigError

type InvalidConfigError struct {
	Reason string
	Path   string
}

Now we need to satisfy the error.Error() interface using a receiver method:

func (e *InvalidConfigError) Error() string {
	return e.Reason
}

Then, if our configReader struct returns an InvalidConfigError, we can print the invalid config file's path using errors.As(err error, target any) bool

if _, err := configReader.Read("/path/to/config.conf"); err != nil {
    var ce *InvalidConfigError
    if errors.As(err, &ce) {
        println(ce.Reason, ce.Path)
    } else {
        println(err)
    }
}

Real World Examples

Many packages encode additional information in custom errors. For example, the kubernetes/apimachinery/pkg/api/errors package contains a StatusError struct that provides significantly richer metadata about an API error than the simple error string. This package includes a multitude of helper functions like IsNotFound(err error) bool, IsAlreadyExists(err error) bool (and more) that allow the caller to pass an error and return a boolean that signifies whether the error is of that particular type.

Under the hood, these methods work using casting and errors.As. As an example, let's take a look at this helper method, ReasonForError:

func ReasonForError(err error) metav1.StatusReason {
	if status, ok := err.(APIStatus); ok || errors.As(err, &status) {
		return status.Status().Reason
	}
	return metav1.StatusReasonUnknown
}

This method attempts to cast an error into an APIStatus struct and then accesses that struct's metadata to return the underlying reason of the error. This is much more useful than using strings.Contains or some other method to determine an error's root cause.

As you can see, it is possible to write write rich error-handling code for your API's end users and developers by using a few simple primitives provided by the errors package