Golang error handling demystified. errors.Is(), errors.As(), errors.Unwrap(), custom errors and more

Error handling in Golang can be tricky to understand. I know I struggled with it and the articles I stumbled upon offered some information but it was a bit cluttered and some used complex examples.

I’ll try to make it simple for you in this article.

Before we start, one article from which I gleaned a lot of information about error handling in Golang is this one, you might want to check it out after you read this post.

If you spot any mistakes in this article please let me know, thank you.

This is a long post, so brace yourself, knowledge is coming.

Ok, let’s begin.

Simple error checking

Errors are values. In the code below we create a new error variable with a certain value. Then we check if one value equals another value.

The value against which we check ( ErrBadInput ) is also called a sentinel value.

import (
	"errors"
	"fmt"
)

var ErrBadInput = errors.New("the input was bad")

func do() error {
	return ErrBadInput
}

func main() {
	err := do()
	if err == ErrBadInput {
		fmt.Println("we have a bad input error")
	}
}

// prints:
// we have a bad input error

Error wrapping with fmt.Errorf() and unwrapping with errors.Unwrap()

Sometimes we want to wrap our errors and return some extra information about the context in which the error occurred.

We use a function such as fmt.Errorf()where we use something called a %w verb to embed another error.

Running the code below we won’t have any output, because the error returned fromdo() has another value other than ErrBadInput.

var ErrBadInput = errors.New("the input was bad")

func do() error {
	return fmt.Errorf("do() extra information, error is %w", ErrBadInput)
}

func main() {
	err := do()
	if err == ErrBadInput {
		fmt.Println("we have a bad input error")
	}
}

// prints:
//

But we can see that the ErrBadInput is wrapped inside the error returned by our do() function. Wouldn’t it be nice to be able to extract it from there…or unwrap it? That way we could check if the error from do() has embedded in it ErrBadInput. Turns out we have a function in the errors package which does exactly that:

var ErrBadInput = errors.New("the input was bad")

func do() error {
	return fmt.Errorf("do() extra information, error is %w", ErrBadInput)
}

func main() {
	err := do()
	if errors.Unwrap(err) == ErrBadInput {
		fmt.Println("we have a bad input error")
	}
}

// prints:
// we have a bad input error

Pretty neat.

But what if we have yet another function that wraps our wrapped error again? A double wrap if you will.

Then it follows logic that we have to unwrap it twice, like so:

var ErrBadInput = errors.New("the input was bad")

func do() error {
	return fmt.Errorf("do() extra information, error is %w", ErrBadInput)
}

func do2() error {
	err := do()
	return fmt.Errorf("do2() extra information, error is %w", err)
}

func main() {
	err := do2()
	if errors.Unwrap(errors.Unwrap(err)) == ErrBadInput {
		fmt.Println("we have a bad input error")
	}
}

// prints:
// we have a bad input error

And so we come to error chain, which is in the words of the official go blog:

The result of unwrapping an error may itself have an Unwrap method; we call the sequence of errors produced by repeated unwrapping the error chain.

Now imagine we have an unknown number of functions which keep wrapping, resulting in a long error chain. It would be pretty difficult to unwrap it using the errors.Unwrap() function

Enter errors.Is()

Unwrapping automatically with errors.Is()

errors.Is() does the unwrapping for us:

var ErrBadInput = errors.New("the input was bad")

func do() error {
	return fmt.Errorf("do() extra information, error is %w", ErrBadInput)
}

func do2() error {
	err := do()
	return fmt.Errorf("do2() extra information, error is %w", err)
}

func main() {
	err := do2()
	// if errors.Unwrap(errors.Unwrap(err)) == ErrBadInput {
	// 	fmt.Println("we have a bad input error")
	// }
	if errors.Is(err, ErrBadInput) {
		fmt.Println("we have a bad input error")
	}
}

// prints:
// we have a bad input error

errors.Is()looks in the error chain to see if we have an underlying error embedded there, whose value equals the value of ErrBadInput

So in a nutshell, use errors.Is() instead of err==ErrMyErr to check if the value of an error is equal to a sentinel error value.

Custom error types

What if we want to implement our own custom error types? Let’s say that we need some extra information passed alongside the actual error which occurred, such as a timestamp?

Enter custom errors. All we need is a struct which implements the error interface. This interface has a single Error() method which returns a string.

Here’s a simple example:

type ErrCustom struct {
	info string
	err  error
}

func (e ErrCustom) Error() string {
	return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}

func do() error {
	return ErrCustom{info: "my info", err: errors.New("bad input")}
}

func main() {
	err := do()
	fmt.Println(err)
}

// prints:
// info is my info, error is bad input

Look in the code above and try to understand it. We have 2 fields, info and err, but we could have as many extra fields as we wanted, for others types of extra information.

Afterwards we implement the error interface by creating a method Error()with ErrCustom as a value receiver. I use a value receiver because we don’t need to change the fields values inside the ErrCustom struct.

Finally, in the do() function we return this custom error with the fields set to certain values.

errors.As()

Now let’s say we need to know if the error we got from a function is of our freshly defined custom error type, ErrCustom. How do we do that?

You will probably immediately think about errors.Is(), but that won’t work. It won’t work because errors.Is() checks 2 error values to see if they are the same (also checking deep in the error chain).

But in the case above we have one value, err and one type ErrCustom. What we need to know is: is err of type ErrCustom?

This is where errors.As() comes to the rescue.

type ErrCustom struct {
	info string
	err  error
}

func (e ErrCustom) Error() string {
	return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}

func do() error {
	return ErrCustom{info: "my info", err: errors.New("bad input")}
}

func main() {
	err := do()
	var errCustom ErrCustom
	if errors.As(err, &errCustom) {
		fmt.Println("we have an err of type ErrCustom")
	}
}

// prints:
// we have an err of type ErrCustom

There are a few changes in the way errors.As() operates.

First argument is an error value, err in our case. Second argument is of type any, which is an alias for interface{}. This second argument should be a pointer to a custom type that implements the error interface, which is the case as ErrCustom implements error interface.

errors.As() is similar with errors.Is() in the fact that it also looks in the error chain to check for a specific error type. And it also takes two error values as arguments, sorta (cause one is an error value, the other a pointer to a error value) but instead of checking if they are equal it checks if arg1 is of type arg2. More on that in the advanced section below.

So if we wrap our custom error with fmt.Errorf() we can still get a correct check with errors.As():

type ErrCustom struct {
	info string
	err  error
}

func (e ErrCustom) Error() string {
	return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}

func do() error {
	return ErrCustom{info: "my info", err: errors.New("bad input")}
}

func do2() error {
	err := do()
	return fmt.Errorf("do2() extra info, error is %w", err)
}

func main() {
	err := do2()
	var errCustom ErrCustom
	if errors.As(err, &errCustom) {
		fmt.Println("we have an err of type ErrCustom")
	}
}

// prints:
// we have an err of type ErrCustom

Intermezzo

Still with me so far? Thank you if you are, here’s a cat meme to clear your brain 😀 (source)

funny cat meme

Let’s recap:

We have 2 major use cases for error handling:

  1. Check if two errors have the same value. The naive approach is err == ErrCustom, the preferred approach is errors.Is(err, ErrCustom). Both err and ErrCustom are error values.
  2. If we have a custom error type and we want to check if an error value err is of type ErrCustom. err is an error value, ErrCustom is a type. We use errors.As(err, &errCustom), where &errCustom is a pointer to an ErrCustom value.

Let’s look into other more complex use cases:

errors.As() with pointer receiver

What if when implementing the error interface you would use a pointer receiver, like func (e *ErrCustom) Error() string {...}?

Then in order to use errors.As() you would need to have the following changes:

type ErrCustom struct {
	info string
	err  error
}

func (e *ErrCustom) Error() string {
	return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}

func do() error {
	return &ErrCustom{info: "my info", err: errors.New("bad input")}
}

func main() {
	err := do()
	var errCustom *ErrCustom
	if errors.As(err, &errCustom) {
		fmt.Println("we have an err of type ErrCustom")
	}
}

// prints:
// we have an err of type ErrCustom

Astute readers will notice something that looks like a pointer to a pointer. The official go blog specifies that:

although it may feel odd to take a pointer to a pointer, in this case it is correct. Think of it instead as taking a pointer to a value of the error type; it so happens in this case that the returned error is a pointer type.

errors.As() with a pointer to a custom error value with specific data

Take a look at the code below:

type ErrCustom struct {
	info string
	err  error
}

func (e ErrCustom) Error() string {
	return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}

func do() error {
	return ErrCustom{info: "my info", err: errors.New("bad input")}
}

func main() {
	err := do()
	var errCustom ErrCustom = ErrCustom{info: "dummy info", err: errors.New("dummy err")}
	if errors.As(err, &errCustom) {
		fmt.Println("we have an err of type ErrCustom")
	}
}

// prints:
// we have an err of type ErrCustom

Maybe you will be surprised that we still get true when checking, since err fields have different values than errCustom. Remember though, with errors.As() we check if an error value err is of type ErrCustom, which it is, even if errCustom has fields with different data.

errors.As() with pointer to an empty interface, interface{}

Take a look at the code below:

type ErrCustom struct {
	info string
	err  error
}

func (e ErrCustom) Error() string {
	return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}

func do() error {
	return ErrCustom{info: "my info", err: errors.New("bad input")}
}

func main() {
	err := do()
	var errCustom interface{}
	if errors.As(err, &errCustom) {
		fmt.Println("we have an err of type ErrCustom")
	}
}
// prints:
// we have an err of type ErrCustom

Again, you may be surprised that the check passes. Looking into the source code, we see that:

// As panics if target is not a non-nil pointer to either a type that implements
// error, or to any interface type.
func As(err error, target any) bool {
...

So we can pass an empty interface as a target argument. Since the empty interface can hold any values it does make sense that value err is of type interface{}

Implementing Unwrap() on our custom error type and calling errors.Is()

Here’s a more complex example:

var ErrBadInput error = errors.New("bad input")

type ErrCustom struct {
	info string
	err  error
}

func (e ErrCustom) Error() string {
	return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}

func (e ErrCustom) Unwrap() error {
	return e.err
}

func do() error {
	return ErrCustom{info: "my info", err: ErrBadInput}
}

func main() {
	err := do()
	if errors.Is(err, ErrBadInput) {
		fmt.Println("is err bad input")
	}
}

// prints:
// is err bad input

If we implement a method called Unwrap() on our custom type, then errors.Is() is going to use this custom method to get to the underlying error value. This you can see in the code above.

Unwrap() + errors.Is() + errors.As()

For an even more mind twisting example (sorry), here’s the following code:

var ErrBadInput error = errors.New("bad input")

type ErrCustom struct {
	info string
	err  error
}

func (e ErrCustom) Error() string {
	return fmt.Sprintf("info is %v, error is %v", e.info, e.err)
}

func (e ErrCustom) Unwrap() error {
	return e.err
}

func do() error {
	return ErrCustom{info: "my info", err: ErrBadInput}
}

func main() {
	err := do()
	if errors.Is(err, ErrBadInput) {
		fmt.Println("is err bad input")
	}

	var errCustom ErrCustom
	if errors.As(err, &errCustom) {
		fmt.Println("is ErrCustom")
	}
}

// prints:
// is err bad input
// is ErrCustom

Wait a second… so our error is both ErrBadInput and ErrCustom? What it’s happening here?

Because we have implemented the method Unwrap() on our custom error type, errors.Is() uses that method, that method returns e.err, and our do() function sets the field value of e.err to ErrBadInput. So the value of err is equal to the value of ErrBadInput (which is a value too if you look in the code).

On the other hand, errors.As() works as usual and reports that err is of type ErrCustom.

Some Inception level stuff, right? It can get a bit confusing, but once you understand how it works behind the scene things are a bit clearer.

fmt.Errorf(“%w”)

If you use fmt.Errorf("%w") (or more specifically the verb %w is included) then the error returned by fmt.Errorf() ” will have an Unwrap method returning the argument of %w, which must be an error” (from the go blog)

Custom Is() function

Some of you may think of a case where we have a custom error type which implements error interface. What if we would use errors.Is() like so:

type ErrCustom struct {
	info string
}

func (e ErrCustom) Error() string {
	return fmt.Sprintf("the error is %v", e.info)
}

func do() error {
	return ErrCustom{info: "do info"}
}

func main() {
	err := do()
	if errors.Is(err, &ErrCustom{}) {
		fmt.Println("it is ErrCustom")
	}
}

// prints:
//

This won’t pass the conditional check. Remember, errors.Is checks if two values are equal, using Unwrap() to get the underlying error value.

What if you do something like this?

type ErrCustom struct {
	info string
}

func (e ErrCustom) Error() string {
	return fmt.Sprintf("the error is %v", e.info)
}

func do() error {
	return ErrCustom{info: "do info"}
}

func main() {
	err := do()
	if errors.Is(err, &ErrCustom{info: "do info"}) {
		fmt.Println("it is ErrCustom")
	}
}

// prints:
//

Even if it seems that we have the same error values, this still won’t work. The way to make this work is to implement the Is() method on our custom error type.

type ErrCustom struct {
	info string
}

func (e ErrCustom) Error() string {
	return fmt.Sprintf("the error is %v", e.info)
}

func (e ErrCustom) Is(target error) bool {
	t, ok := target.(*ErrCustom)
	if !ok {
		return false
	}
	return (e.info == t.info)
}

func do() error {
	return ErrCustom{info: "do info"}
}

func main() {
	err := do()
	if errors.Is(err, &ErrCustom{info: "do info"}) {
		fmt.Println("it is ErrCustom with info='do info'")
	}
}

// prints:
// it is ErrCustom with info='do info'

Now the check passes.

[edit] errors.Join()

After I published this article on reddit and received the hug of death (server went down), someone pointed out that I should also mention errors.Join()

	err1 := errors.New("err1")
	err2 := errors.New("err2")
	err := errors.Join(err1, err2, nil)

	fmt.Println(err)

	//prints:
	// err1
	// err2

The official documentation states:

“Join returns an error that wraps the given errors. Any nil error values are discarded. Join returns nil if every value in errs is nil. The error formats as the concatenation of the strings obtained by calling the Error method of each element of errs, with a newline between each string.”

and

“A non-nil error returned by Join implements the Unwrap() []error method.”

This means we can use errors.Is() to check for error values, like so:

	err1 := errors.New("err1")
	err2 := errors.New("err2")
	err := errors.Join(err1, err2, nil)

	if errors.Is(err, err1) {
		fmt.Println("it's err1")
	}
	if errors.Is(err, err2) {
		fmt.Println("it's err2")
	}

	//prints:
	// it's err1
	// it's err2

Final words

This was a long read, thank you for reaching the end! In trying to explain things thoroughly I might have created another hard to understand article, whereas the intention was to create an article that made things easy to understand. If so, I apologize.

Thank you for reading and please let me know your suggestions and thoughts in the comments below.

Leave a Reply

Your email address will not be published. Required fields are marked *