December 8, 2022

Go Errors Cheat Sheet 2022

Error handling is a critical part of any software development project. In this blog post, we will be discussing the different types of errors in Go, including Sentinel Errors and Error Types, and how to compare errors using the errors.Is and errors.As functions. We will also cover the common ‘gotcha’ of comparing error types when working with pointer types that implement the error interface, and how to encapsulate the complexity of error type comparison in your own functions.

Defining Error Types

Sentinel Errors

Sentinel errors are errors that are defined as a package level variable. They are used to compare against the error returned from a function. A common example is the EOF sentinel error that is defined in the io package. Here’s an example of how to define a sentinel error.

var (
    EOF = errors.New("end of file") // ~(-1) EOF sentinel error definition
)

Error Types

Error Types are errors that are defined as struct types that implement the error interface. These are typically defined in the same package that returns them and because they are a struct, they can be used to provide additional information about the error.

type ErrNotFound struct {
    Name string
}

func (e ErrNotFound) Error() string {
    return fmt.Sprintf("not found: %s", e.Name)
}

As a general guideline you should

  • use Sentinel Errors when you want to define an expected error that package consumer may encounter
  • use Error Types when you are defining an unexpected error.

Comparing Errors

There are two main ways to compare errors in Go

  • errors.Is - Used for comparing against a sentinel error
  • errors.As - Used for comparing against an error type

These functions are required for comparing errors because in Go we often wrap errors to provide additional content which can make direct comparison difficult. These two functions perform a recursive comparison of the error chain to determine if the error is equal to the target error.

Sentinel Error Comparison

var (
    ErrNotFound = errors.New("not found")
)

func main() {
    err := errors.New("not found")
    if errors.Is(err, ErrNotFound) {
        fmt.Println("not found")
    }
}

Error Type Comparison

When comparing error types you’ll use a pointer to the error type to perform the comparison with the errors.As function.

type ErrNotFound struct {
    Name string
}

func (e ErrNotFound) Error() string {
    return fmt.Sprintf("not found: %s", e.Name)
}

func main() {
    err := &ErrNotFound{Name: "hayden"}
    if errors.As(err, &ErrNotFound{}) { // ~(-1) Argument 2 must be a pointer
        fmt.Println("not found:", e.Name)
    }
}

With error types there is a common ‘gotcha’ when working with pointer types that implement the error interface. If we instead change the ErrNotFound receiver to use a pointer, we can no longer validate that the error is of type ErrNotFound because the errors.As function requires the second argument to be a pointer to an error type. At first this can seem a little confusing. Below is an example of how this works in practice.

func (e *ErrNotFound) Error() string { // ~(-1) Pointer receiver
    return fmt.Sprintf("not found: %s", e.Name)
}

func main() {
    err := &ErrNotFound{Name: "hayden"}
    var e *ErrNotFound
    if errors.As(err, &e) { // ~(-1) Pointer to error type
        fmt.Println("not found:", e.Name)
    }
}

At first you may think of this as passing a pointer to a pointer. However I’ve found the way to keep this clear in your mind is to remember that we need a pointer to the error type not a pointer to the concrete type. That is why we first declare the variable e as a pointer to the error type and then pass the address of that variable to the errors.As function.

Because of this complexity around error type comparison, you will often see functions like the one below be exposed from a package to ensure error type comparison is done correctly.

func IsNotFoundError(err error) bool {
    var e *ErrNotFound
    return errors.As(err, &e)
}

This encapsulates the complexity of error type comparison and allows the package consumer to easily compare errors without having to worry about the implementation details.

Handling Errors

Handling errors is a critical practice in software development, let’s look at a few of the common ways that you’ll handle errors (or not) in go.

Ignoring Errors

In some cases, you will want to ignore errors. It’s important to explicitly ignore the error to indicate intent. Below are two examples that show a function called doThing where the function signature signals that it may return an error. The first code example shows an implicitly ignored error in which we’ve neglected to convey to any future readers that we know an error is possible. The second example shows intent to ignore the errors. This tells the future readers that we know it may return an error, but for some reasons we’ve decided we can ignore it in this case.

This pattern can be particularity useful in testing where your may need to generate an io.Reader from a known string that you know won’t produce an error.

Bad

package main

func doThing() error {
    return nil
}

func main() {
    doThing()
}

Good

package main

func doThing() error {
    return nil
}

func main() {
    _ = doThing()
}

Wrapping Errors

Wrapping errors in Go, since v1.13, is pretty straight forward. With the introduction of Errorf and the %w verb, we can wrap errors with a single line of code. Below is an example of how to wrap an error with a custom message.

func func1() error {
    return errors.New("something went wrong")
}

func func2() error {
    err := func1()
    if err != nil {
        return fmt.Errorf("func1: %w", err)
    }
}

You will use this pattern when you want to provide additional context to an error that is returned from a function. This is a common pattern in Go and is used in the standard library. The general guideline is to wrap errors when you want to provide additional context to an error that is returned from a function.

Handling Deferred Errors

Deferred functions provide a convenient way to handle the cleanup of resources. This is used all the time when working with files, database connections, and other resources that need to be cleaned up. What is not done as often, is the proper handling of an error that happens in a deferred function. Below is an example of how to handle an error that happens in a deferred function.

func readFile() (err error)) {
    f, err = os.Open("file.txt")
    if err != nil {
        return err
    }


    defer func() { // ~(-1) Ensure the file is closed
        err = f.Close()
    }()

    // do something with f
}

This gets more complicated when you’re working with multiple deferred functions, or when you have a possible error after the deferred declaration. However, the general pattern is the same, except you will need to check if an error is already present


func mayError(f *os.File) (error) {
    return errors.New("something went wrong")
}

func readFile() (err error)) {
    f, err = os.Open("file.txt")
    if err != nil {
        return err
    }

    defer func() {
        closeErr := f.Close()
        if closeErr != nil {
            if err != nil {
                err = fmt.Errorf("%w: %s", err, closeErr)
                return
            }

            err = closeErr
        }
    }()

    err = mayError(f)

    if err != nil {
        return err
    }
}

The example above shows how to handle multiple errors that happen in deferred functions. The general pattern is to check if an error is already present. if it is, then we wrap the new error with the existing error. If there is no existing error, then we set the named return value to the new error. While sometimes you may be able to get away with wrapping an error, it’s not always an option. You will need to decide based on your specific use case.

Conclusion

In this post we’ve looked at the basics of error handling in Go. We’ve looked at creating custom error types, how to compare error types, and handling errors. We’ve also looked at some common patterns for handling errors in Go. Here’s some of the key takeaways

  • Use a sentinel error when you want to define an expected Error
  • Use an error type when it’s unexpected, or you want to provide additional context
  • errors.Is - Used for comparing against a sentinel error
  • errors.As - Used for comparing against an error type
  • Remember to pass a pointer to the error type when using errors.As
  • Use implicit returns when you want to return an error from a deferred function