Software Ā· Go

Go 101, everything in Go is an error, now how do we wrap them? šŸŽ

Contents↑ Back to top
↑

I had wanted to talk about this topic for a long time, because when I started my professional life in Golang I really didn’t know what it was and I did something pretty rudimentary. It wasn’t until some time later, already in my current job, that my boss said to me, ā€œDo you know what wrapping errors are?ā€. And I, with all my cunning, said yes. It turns out I didn’t have the faintest idea. When I explained what I thought it was, and showed it to him with my very rudimentary way, he said, ā€œok, that’s not itā€. And since I started working with them, you have no idea how dependent I’ve become on this feature of Go and how essential it is. Because yes, almost everything in Go is an error, and I mean that literally, almost all functions return our known and sometimes loved, sometimes hated err :=.

Well, to make a long story short, wrapping error is wrapping errors without losing the information along the way, to be able to see where the error comes from.

At first I did this with a lot of confidence.

return fmt.Errorf("something failed: %v", err)

Or if I wanted something more sophisticated, I built a structure that could have different fields and a dedicated one as a flag or sentinel of where the error could happen.

A lot of work for a feature that Golang had since version 1.13. If only I had read the documentation at that moment… but that’s how one learns, hahaha.

So in this post we are going to see the whole topic of wrapping, but with one condition. We are going to prove everything with tests. And yes, it turns out we are going to combine two of the best features Go has, errors and tests. No ā€œtrust me, it worksā€. Every idea will have a test that proves it, and all the code in this post compiles and passes with go test.

All the code is in a public repo, so you can clone it and run it, Rivaldito/go101.

The Russian doll of errors šŸŖ†

Before touching code, I want us to form an image of what wrapping errors really means. Let’s look at this concept with a bit of culture, let’s think of errors in Go with an image. Do you know matryoshkas, those Russian dolls that have another doll inside, and another inside, and another inside, and so on?

Well, a wrapped error is exactly that.

  • The outer doll is the error you see at the end, with a message like for example service.GetUserByID id=42: store.FindUserByID id=42: not found.
  • Each layer the error passes through adds its little piece of context, for example, ā€œthis happened in the serviceā€, ā€œthis happened in the storeā€.
  • And the tiny doll in the center is the real cause, not found.

The trick of all this, and what I want you to take away from this post, is to learn two things, putting one doll inside another without breaking it, and finding a specific doll without having to open all of them by hand.

For the examples we are going to use a mini app with three layers, with the idea of simulating a real code service and, above all, the tests.

Test  →  Service.GetUserByID  →  Store.FindUserByID  →  ErrNotFound

The Store looks up users in a map, the Service uses the store, and the tests check what arrives at the very top.

Okay, but now yes, what we skipped and ignored, what is an error in Go? šŸ¤”

In Go there are no exceptions, nor try/catch as maybe there is in Python or JavaScript. An error is simply a value that you return, like you would return a number or a string. And that value has to fulfill a super ā€œtinyā€ interface.

type error interface {
    Error() string
}

That is, anything that has an Error() method that returns a string is already an error. It’s that simple. That’s why you’ll see this pattern so much in Go.

u, err := store.FindUserByID(42)
if err != nil {
    // something went wrong, I do something with err
}

The easiest way to create an error is with errors.New.

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

And here comes the really interesting part, if that error travels up through three or four functions, when it arrives at the top it only says not found. Not found what? Where? What id? No idea. šŸ¤·ā€ā™‚ļø

That is why wrapping exists, each function the error passes through puts a little layer of context on top, but without destroying the original error.

%w vs %v, one letter that changes everything šŸŽ

Here we have to be very careful. In Go we use % followed by a letter to print something. Many times the letter you use (or used) was %v (I really don’t want to explain what each letter is for, surely in a next post). Well, since Go 1.13, fmt.Errorf has a special verb, %w (for wrap, to wrap). The difference with the good old %v is this.

  • %v takes the error, pulls out its text and pastes it into the new message. The original error disappears, what remains is just a string.
  • %w does the same with the text, but also stores the original error inside the new error. It can be taken out again.

And this is where many people overlook it, at first glance the two messages are IDENTICAL. If you print the error, you see no difference. That’s why this topic is so neglected, or given so little importance at very basic levels in this language, and that’s why we need tests. šŸ‘€

Our first wrap

For this first example of wrap of errors we are going to use the code I mentioned before. We are going to simulate a very basic service, so our code will be the following. This is the store and the service we are going to use throughout the post.

package errwrap

import (
	"errors"
	"fmt"
)

// ErrNotFound is returned when a user is not found in the store.
var (
	ErrNotFound     = errors.New("not found")
	ErrUnauthorized = errors.New("unauthorized")
)

// User represents a user in the system.
type User struct {
	ID    int
	Email string
}

// Store represents a simple in-memory user store.
type Store struct {
	users map[int]User
}

// NewStore creates a new Store with some initial users.
func NewStore() *Store {
	return &Store{
		users: map[int]User{1: {ID: 1, Email: "[email protected]"}},
	}
}

// FindUserByID retrieves a user by their ID, returning an error if the user is not found.
func (s *Store) FindUserByID(id int) (User, error) {
	u, ok := s.users[id]
	if !ok {
		return User{}, fmt.Errorf("store.FindUserByID id=%d: %w", id, ErrNotFound)
	}
	return u, nil
}

// Service holds the business logic on top of the Store.
type Service struct {
	store *Store
}

// NewService creates a new Service with the given Store.
func NewService(store *Store) *Service {
	return &Service{store: store}
}

// GetUserByID retrieves a user's email by their ID, returning an error if the user is not found.
func (s *Service) GetUserByID(id int) (string, error) {
	user, err := s.store.FindUserByID(id)
	if err != nil {
		return "", fmt.Errorf("service.GetUserByID id=%d: %w", id, err)
	}
	return user.Email, nil
}

// GetUserByIDOpaque retrieves a user's email by their ID, returning an error if the user is not found.
func (s *Service) GetUserByIDOpaque(id int) (string, error) {
	user, err := s.store.FindUserByID(id)
	if err != nil {
		return "", fmt.Errorf("service.GetUserByIDOpaque id=%d: %v", id, err)
	}
	return user.Email, nil
}

And to check the code we will use these tests designed to fail, to get an error.

func TestWrapKeepChan(t *testing.T) {
	// Init the service
	svc := NewService(NewStore())
	// Get a wrong user
	_, err := svc.GetUserByID(333)
	// If we got nil, something is really wrong with our minimal code
	if err == nil {
		t.Fatal("expected error, got nil")
	}
	// We want this error
	want := "service.GetUserByID id=333: store.FindUserByID id=333: not found"
	if err.Error() != want {
		t.Fatalf("err.Error() = %q; want %q", err.Error(), want)
	}

	t.Logf("the full error: %v", err) //in this case we use %v, because t.Logf dont suport %w, and we want to see the error chain
	t.Logf("inside the error: %v", errors.Unwrap(err))
	t.Logf("inside the inside error: %v", errors.Unwrap(errors.Unwrap(err)))
}

func TestWrapOpaque(t *testing.T) {
	// Init the service
	svc := NewService(NewStore())
	// Get a wrong user
	_, err := svc.GetUserByIDOpaque(333)
	// If we got nil, something is really wrong with our minimal code
	if err == nil {
		t.Fatal("expected error, got nil")
	}
	// We want this error
	want := "service.GetUserByIDOpaque id=333: store.FindUserByID id=333: not found"
	if err.Error() != want {
		t.Fatalf("err.Error() = %q; want %q", err.Error(), want)
	}

	t.Logf("the full error: %v", err) //in this case we use %v, because t.Logf dont suport %w, and we want to see the error chain
	t.Logf("inside the error: %v", errors.Unwrap(err))
	t.Logf("inside the inside error: %v", errors.Unwrap(errors.Unwrap(err)))
}

I want you to look closely at the code. Let’s analyze the two functions, GetUserByID and GetUserByIDOpaque, they do exactly the same thing, however, in the latter for the error we use %v instead of %w. What implications will this have?

Well, let’s run the tests.

When we run TestWrapKeepChan we get this output.

/home/apolo/go101/errWrap/errWrap_test.go:23: the full error: service.GetUserByID id=333: store.FindUserByID id=333: not found
/home/apolo/go101/errWrap/errWrap_test.go:24: inside the error: store.FindUserByID id=333: not found
/home/apolo/go101/errWrap/errWrap_test.go:25: inside the inside error: not found

We see that the t.Log was able to use the errors.Unwrap function and take the error apart like layers or Russian dolls. Great, we see that we don’t lose information.

But what will happen if we run the second test, TestWrapOpaque?

/home/apolo/go101/errWrap/errWrap_test.go:43: the full error: service.GetUserByIDOpaque id=333: store.FindUserByID id=333: not found
/home/apolo/go101/errWrap/errWrap_test.go:44: inside the error: <nil>
/home/apolo/go101/errWrap/errWrap_test.go:45: inside the inside error: <nil>

Okay, we see that the first t.Log has the error, and if we go down we see that we have… nil? Yes, nil! We lost the information, we can no longer find the root, everything is combined. And this is very important for us to understand, since by using %v we not only lose the Unwrap, we also lose the use of two very important functions that Golang has to detect whether an error or some element is contained, errors.Is and errors.As. We will see them later on.

But first, a little bit of history šŸ“œ

Before Go 1.13 (way back in 2019) this didn’t exist in the standard library. The usual thing was to do fmt.Errorf("...: %v", err) and that’s it, you lost the cause. Whoever wanted something better used third-party libraries like github.com/pkg/errors, which had its own errors.Wrap and errors.Cause, and since Go is full of errors, the use of this library was so popular that the Go team ended up putting the idea into the standard library, and from there came %w, errors.Is, errors.As and errors.Unwrap.

And well, but what the heck does %w do inside? šŸ”¬

This made me very curious, to be honest, so I went to look at the source code of fmt (it’s in src/fmt/errors.go, in case you want to snoop around šŸ‘€). It turns out fmt.Errorf doesn’t always return the same thing. Depending on how many %w you put in it, it returns a different type.

  • No %w, a plain error, just like errors.New. Only text.
  • One %w, a *fmt.wrapError.
  • Two or more %w, a *fmt.wrapErrors (with an s, we’ll get to that later).

And the wrapError is super simple. It’s basically this.

type wrapError struct {
	msg string // the already formatted text
	err error  // the doll inside
}

func (e *wrapError) Error() string { return e.msg }
func (e *wrapError) Unwrap() error { return e.err }

That is, it stores the already built message for when you print it, and it stores the original error separately so you can take it out again with Unwrap. That’s all the magic. There’s nothing more. šŸŖ„

And we can check it with a test.

func TestErrorfConcreteTypes(t *testing.T) {
	cases := []struct {
		name string
		err  error
		want string
	}{
		{"without %w", fmt.Errorf("x: %v", ErrNotFound), "*errors.errorString"},
		{"with %w", fmt.Errorf("x: %w", ErrNotFound), "*fmt.wrapError"},
		{"with %w %w", fmt.Errorf("x: %w %w", ErrNotFound, ErrUnauthorized), "*fmt.wrapErrors"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := reflect.TypeOf(c.err).String(); got != c.want {
				t.Errorf("type = %s; want %s", got, c.want)
			}
		})
	}
}

That’s why I love Go, something so useful is very simple in the code.

errors.Is, are you out there, ErrNotFound? šŸ”

Before getting into this very useful Go function, let’s first go over a bit of vocabulary. A sentinel error is an error that you declare only once as a package variable, so anyone can compare against it. Surely you have already used one without knowing it had a name, for example you may be familiar with these, io.EOF, sql.ErrNoRows, os.ErrNotExist… they are all sentinels.

In our example we have two.

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

Before wrapping, the way to ask was with ==. I did it like that a long time ago.

if err == ErrNotFound { ... }

The problem is that now err is no longer ErrNotFound, it’s a doll that contains ErrNotFound two layers deeper. Remember that this error went through many layers, and in real production code, where there are multiple services or client interfaces, the error can go through 4 or 5 layers or more. If we use that approach, we’ll find that err is a different value from the one we are looking for, so == says no. It’s not a lie, but it doesn’t help us either. šŸ˜…

This is where errors.Is comes in. What it does is open the doll layer by layer and at each one ask ā€œare you the one I’m looking for?ā€. If any says yes, it returns true.

_, err := svc.GetUserByID(42)

err == ErrNotFound           // false: err is the service layer
errors.Is(err, ErrNotFound)  // true: it finds it two layers down

The nice thing is that you don’t care how many layers there are. If tomorrow someone puts a repository between the service and the store, errors.Is keeps working the same without touching anything.

I invite you to read the implementation of the Is function code, it’s very short, a few lines of code and a for loop.

Okay, and what is this for in real life?

The typical case is an HTTP handler that has to decide which code to return depending on what failed.

switch {
case errors.Is(err, ErrNotFound):
	http.Error(w, "user not found", http.StatusNotFound)
case errors.Is(err, ErrUnauthorized):
	http.Error(w, "forbidden", http.StatusForbidden)
default:
	http.Error(w, "internal error", http.StatusInternalServerError)
}

The handler doesn’t have to know anything about how the store works inside. It just asks ā€œwas this a not found?ā€ and acts.

Edge cases with nil

It’s always a good idea to have a test for the weird edges, because that’s exactly where one assumes things.

func TestNilIsNotWrapped(t *testing.T) {
	var err error
	if errors.Is(err, ErrNotFound) {
		t.Error("nil is not ErrNotFound")
	}
	if !errors.Is(nil, nil) {
		t.Error("errors.Is(nil, nil) should be true")
	}
}

A nil error is never any sentinel, but errors.Is(nil, nil) is true. Weird but logical, nil == nil.

Watch out for one thing here, as soon as you export a sentinel and wrap it with %w, it becomes part of your package’s public API. There will be code out there doing errors.Is(err, yourpackage.ErrNotFound), so don’t change it or delete it lightly.

errors.As, give me the error, but with its data 🪪

Sentinels are pretty good if we want to ask ā€œwhat happened?ā€, but they have a limit, they are just a fixed text. They can’t tell you which form field was wrong, or what code the database returned.

But Go gives us the ability to go a bit further. We can create our own error type, a struct with the data we care about and an Error() method to fulfill the interface.

Let’s look at this example.

type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation failed on %q: %s", e.Field, e.Reason)
}

And we use it in a validation and in the service.

func ValidateEmail(email string) error {
	if email == "" {
		return &ValidationError{Field: "email", Reason: "empty"}
	}
	return nil
}

func (s *Service) UpdateEmail(id int, email string) error {
	if err := ValidateEmail(email); err != nil {
		return fmt.Errorf("service.UpdateEmail id=%d: %w", id, err)
	}
	// Simulate updating the email in the store
	if _, err := s.store.FindUserByID(id); err != nil {
		return fmt.Errorf("service.UpdateEmail id=%d: %w", id, err)
	}
	return nil
}

Now, if UpdateEmail fails because of validation, we get a doll with a *ValidationError inside. How do we take it out to read the Field? With errors.As.

The difference with Is in one sentence.

  • errors.Is asks, is this specific error? (compares values)
  • errors.As asks, is there any error of this type? If so, give it to me (compares types and copies it into a variable)
func TestErrorsAs(t *testing.T) {
	err := NewService(NewStore()).UpdateEmail(1, "")
	var ve *ValidationError
	t.Log(err)
	if !errors.As(err, &ve) {
		t.Fatalf("we expect *ValidationError in the chain; err = %v", err)
	}

	t.Log(ve.Field)
	t.Log(ve.Reason)
}

And we get this output.

=== RUN   TestErrorsAs
    /home/apolo/go101/errWrap/errWrap_test.go:76: service.UpdateEmail id=1: validation failed on "email": empty
    /home/apolo/go101/errWrap/errWrap_test.go:81: email
    /home/apolo/go101/errWrap/errWrap_test.go:82: empty
--- PASS: TestErrorsAs (0.00s)
PASS
ok      github.com/Rivaldito/go101/errWrap      0.005s

Let’s clarify As, since there are some typical mistakes

  • Passing ve instead of &ve. errors.As does a panic, because the destination has to be a pointer that is not nil.
  • The wrong mold. If your function returns &ValidationError{} (pointer) but you declare var ve ValidationError (without *), errors.As also does a panic. Since Error() is defined on the pointer, plain ValidationError doesn’t even fulfill the error interface. The good news, go vet (which runs by itself with go test) warns you before you get to run it. The mold has to be exactly the type you return.
  • Using a type assertion err.(*ValidationError). This only looks at the outer layer. With wrapping, the outer layer is the one from fmt.Errorf, so it fails. errors.As looks at all of them.

The mold can also be an interface šŸ”Œ

This is something I didn’t know and it blew my mind a little bit 🤯, the mold of errors.As doesn’t have to be a concrete struct. It can be an interface.

And what is that for? To ask about what the error knows how to do, instead of asking what exact type it is. Imagine an HTTP client that has to decide whether to retry a request. It doesn’t care whether the failure was a timeout, a dropped connection or a saturated server. It only cares about one thing, can it be retried or not?

So we define an interface with that ā€œcapabilityā€.

type Retryable interface {
	error
	Retryable() bool
}

And any error that has a Retryable() method fulfills it automatically. For example, a timeout.

type TimeoutError struct{ Op string }

func (e *TimeoutError) Error() string   { return e.Op + ": timeout" }
func (e *TimeoutError) Retryable() bool { return true }

And now the nice part. The function that decides whether to retry doesn’t even know TimeoutError.

func ShouldRetry(err error) bool {
	var r Retryable
	return errors.As(err, &r) && r.Retryable()
}

errors.As goes opening the dolls and at each layer asks ā€œdo you have a Retryable() method?ā€. If it finds one, it puts it in r and we can already call it. Tomorrow someone creates a ConnResetError with its own Retryable() and ShouldRetry works without touching a single line. šŸ™Œ

The test, in table format as always.

func TestAsWithInterface(t *testing.T) {
	tests := []struct {
		name string
		err  error
		want bool
	}{
		{"wrapped timeout", fmt.Errorf("client.Get: %w", &TimeoutError{Op: "dial"}), true},
		{"not found", fmt.Errorf("client.Get: %w", ErrNotFound), false},
		{"nil", nil, false},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := ShouldRetry(tt.err); got != tt.want {
				t.Errorf("ShouldRetry(%v) = %v; want %v", tt.err, got, tt.want)
			}
		})
	}
}

Notice the nil case, errors.As with a nil error simply returns false, it doesn’t blow up. So ShouldRetry doesn’t need a separate if err == nil.

Crafting your own matryoshkas

So far all the layers were put by fmt.Errorf with %w. But your own error types can also wrap other errors. They only need one method.

Unwrap() error

If an error has that method, errors.Is and errors.As know there is another doll inside and open it. It’s that easy.

And there is a second optional method, even more interesting.

Is(target error) bool

With it your error can say ā€œhey, I’m equivalent to such sentinelā€, even if it doesn’t have it inside. It’s as if the doll said ā€œI’m not the one you’re looking for, but I count as herā€. 🄸

An example

Imagine a database error that stores the query, a numeric code and the original cause. We want any error with code 404 to behave like ErrNotFound, so that the HTTP handler from before works without changing anything.

type QueryError struct {
	Query string
	Code  int
	Err   error
}

func (e *QueryError) Error() string {
	return fmt.Sprintf("query %q (code %d): %v", e.Query, e.Code, e.Err)
}

// Unwrap lets errors.Is/As reach the cause.
func (e *QueryError) Unwrap() error { return e.Err }

// Is makes a 404 equivalent to ErrNotFound.
func (e *QueryError) Is(target error) bool {
	return target == ErrNotFound && e.Code == 404
}

Notice that in Error() I use %v and nothing happens, because there I’m only building the text. The one that connects the chain is the Unwrap method.

The test covers the three ways of looking at this error, plus the negative case.

func TestCustomIs(t *testing.T) {
	cause := errors.New("no rows")
	err := fmt.Errorf("repo: %w", &QueryError{Query: "SELECT 1", Code: 404, Err: cause})

	if !errors.Is(err, ErrNotFound) {
		t.Error("Code 404 should be equivalent to ErrNotFound")
	}
	if !errors.Is(err, cause) {
		t.Error("the original cause should still be reachable through Unwrap")
	}
	var qe *QueryError
	if !errors.As(err, &qe) || qe.Code != 404 {
		t.Errorf("errors.As failed or wrong Code: %v", qe)
	}

	other := &QueryError{Query: "SELECT 1", Code: 500, Err: cause}
	if errors.Is(other, ErrNotFound) {
		t.Error("Code 500 should not be equivalent to ErrNotFound")
	}
}
  • The first if works thanks to our Is method, there is no ErrNotFound inside, but the 404 counts as one.
  • The second works thanks to Unwrap, the no rows cause is still down there.
  • The third is the good old errors.As taking out the *QueryError to read its code.
  • And the last one confirms that a 500 doesn’t pass itself off as ErrNotFound. This kind of negative test is the one that catches the most bugs, so don’t skip it.

Another way to use Is, comparing by type

In the example above, Is compared against a sentinel. But there is also another trick that is used quite a lot, that your Is says yes to any error of its same type, no matter what data it carries inside.

Why would this be needed? Because when your errors are pointers, == compares memory addresses. Two &RateLimitError{} created in different places are two different pointers, so errors.Is never sees them as equal, even if they have exactly the same fields. 🫠

type RateLimitError struct{ RetryAfter int }

func (e *RateLimitError) Error() string {
	return fmt.Sprintf("rate limited, retry after %ds", e.RetryAfter)
}

// Any *RateLimitError counts as equal.
func (e *RateLimitError) Is(target error) bool {
	_, ok := target.(*RateLimitError)
	return ok
}

func TestIsByType(t *testing.T) {
	err := fmt.Errorf("api: %w", &RateLimitError{RetryAfter: 30})
	if !errors.Is(err, &RateLimitError{}) {
		t.Error("any *RateLimitError should match")
	}
	if errors.Is(err, ErrNotFound) {
		t.Error("should not be ErrNotFound")
	}
	// Without an Is method, two different pointers are never ==.
	a, b := &TimeoutError{Op: "x"}, &TimeoutError{Op: "x"}
	if errors.Is(a, b) {
		t.Error("different pointers should not match without Is")
	}
}

The last part of the test is the important one, TimeoutError has no Is method, and that’s why two identical timeouts don’t match. With RateLimitError they do, because we told it how to compare itself.

Now, being honest, if what you want is to know ā€œis there an error of this type?ā€, errors.As already does that without writing anything extra. The Is by type makes sense when you want your error to be usable in a switch with errors.Is alongside the sentinels, like in the HTTP handler from before.

How errors.Is works inside

Now that we know the two methods, we can see it as a complete set. At each layer of our matryoshka, errors.Is does this.

  1. Is this error == to the one I’m looking for? If so, true.
  2. Does it have an Is(error) bool method and does it return true? If so, true.
  3. If not, call Unwrap() and repeat with the inner layer. If there are no more layers, false.

errors.As does the same, but with an optional As(any) bool method. The truth is that you almost never need to implement it, with Unwrap it’s plenty.

Watch out for this, your Is method must not call Unwrap or walk the chain on its own. errors.Is already does that. Your Is only has to answer for its own layer.

Several errors at once, errors.Join 🧺

So far each error had a single doll inside. But there are cases where several things fail at once and you want to return all of them, not just the first one.

The classic example is validating a form. Imagine you fill in a registration with a wrong id and an empty email. What is more useful, that it tells you ā€œthe id is wrongā€, you fix it, you submit again and then it tells you ā€œah, and the email tooā€? Or that it tells you both things at once? 😤

Since Go 1.20 you can. An error can wrap several errors, and the Russian doll becomes more like a basket with several dolls inside. There are two ways to build it.

  • errors.Join(err1, err2, ...)
  • fmt.Errorf with more than one %w in the same message
func ValidateUser(u User) error {
	var errs []error
	if u.ID <= 0 {
		errs = append(errs, &ValidationError{Field: "id", Reason: "must be positive"})
	}
	if err := ValidateEmail(u.Email); err != nil {
		errs = append(errs, err)
	}
	return errors.Join(errs...) // nil if there are no errors
}

func DoubleWrap() error {
	return fmt.Errorf("both: %w and %w", ErrNotFound, ErrUnauthorized)
}

I love the ValidateUser pattern, since we accumulate errors in a slice and at the end you pass it all to errors.Join. If the slice is empty, errors.Join returns nil, so you don’t need any if len(errs) > 0.

The tests

func TestJoin(t *testing.T) {
	err := ValidateUser(User{ID: 0, Email: ""})
	if err == nil {
		t.Fatal("expected error")
	}
	joined, ok := err.(interface{ Unwrap() []error })
	if !ok {
		t.Fatal("errors.Join should implement Unwrap() []error")
	}
	if n := len(joined.Unwrap()); n != 2 {
		t.Errorf("len = %d; want 2", n)
	}
	var ve *ValidationError
	if !errors.As(err, &ve) {
		t.Fatal("errors.As should find the first ValidationError")
	}
	if ve.Field != "id" {
		t.Errorf("first Field = %q; want id", ve.Field)
	}
	if ValidateUser(User{ID: 1, Email: "[email protected]"}) != nil {
		t.Error("errors.Join with no errors must return nil")
	}
}

func TestMultiplePercentW(t *testing.T) {
	err := DoubleWrap()
	if !errors.Is(err, ErrNotFound) || !errors.Is(err, ErrUnauthorized) {
		t.Errorf("both sentinels should be in the chain: %v", err)
	}
	if errors.Unwrap(err) != nil {
		t.Error("errors.Unwrap returns nil with several %w")
	}
}

These two tests make clear several things that are not at all obvious.

  1. There is a different Unwrap. Multiple errors don’t have Unwrap() error, they have Unwrap() []error (returns a slice). That’s why in the test I do a type assertion to interface{ Unwrap() []error } to be able to count how many errors are inside.
  2. errors.Unwrap returns nil. This confuses a lot of people. errors.Unwrap only knows how to call Unwrap() error (the single-error one), so with a basket of errors it finds nothing and returns nil. To see the branches, use the type assertion above.
  3. Is and As do walk everything. errors.Is and errors.As understand both forms of Unwrap and look inside each branch. That’s why DoubleWrap answers yes to both ErrNotFound and ErrUnauthorized.
  4. As returns the first one it finds. The walk is depth first and in order, so in TestJoin it returns the error of the id field (the first one we put in), not the one of the email.

And a small practical detail, the errors.Join message separates each error with a line break ("a\nb"). If you are going to put it in a single-line log, keep it in mind, because it can break the format.

Opening all the dolls by hand 🌳

We already know that errors.Is and errors.As walk everything for us. But to really understand how they do it, I think it’s super useful to write that walk by hand at least once. And along the way we get a cool function for debugging, you pass it an error and it prints all its layers.

The trick is that now there are two kinds of Unwrap.

  • Unwrap() error, a single doll inside (the %w one and our types).
  • Unwrap() []error, several dolls inside (the errors.Join one and several %w).

So the chain is no longer a straight line, it’s a tree. To walk it I use a stack, I pop an error, save its text, and push onto the stack whatever it has inside.

func Walk(err error) []string {
	var layers []string
	stack := []error{err}
	for len(stack) > 0 {
		e := stack[len(stack)-1]
		stack = stack[:len(stack)-1]
		if e == nil {
			continue
		}
		layers = append(layers, e.Error())
		switch u := e.(type) {
		case interface{ Unwrap() error }:
			stack = append(stack, u.Unwrap())
		case interface{ Unwrap() []error }:
			children := u.Unwrap()
			for i := len(children) - 1; i >= 0; i-- {
				stack = append(stack, children[i])
			}
		}
	}
	return layers
}

A small detail, the children are pushed onto the stack in reverse. Since a stack pops the last thing that went in first, if I push them in reverse they come out in the original order. That way the walk is the same one errors.Is does, depth first and from left to right.

Two tests, one with a straight chain and another with a tree.

func TestWalk(t *testing.T) {
	_, err := NewService(NewStore()).GetUserByID(42)
	got := Walk(err)
	want := []string{
		"service.GetUserByID id=42: store.FindUserByID id=42: not found",
		"store.FindUserByID id=42: not found",
		"not found",
	}
	if !reflect.DeepEqual(got, want) {
		t.Errorf("Walk = %q; want %q", got, want)
	}
}

func TestWalkTree(t *testing.T) {
	err := fmt.Errorf("handler: %w", errors.Join(ErrNotFound, ErrUnauthorized))
	got := Walk(err)
	want := []string{
		"handler: not found\nunauthorized",
		"not found\nunauthorized",
		"not found",
		"unauthorized",
	}
	if !reflect.DeepEqual(got, want) {
		t.Errorf("Walk = %q; want %q", got, want)
	}
}

In the second test you can perfectly see what the tree looks like, at the top is the handler, below the Join basket, and from the basket hang the two little dolls. And once again the line break \n that errors.Join puts between messages appears.

Careful, this is for understanding and for debugging. In real code, to ask things about an error, keep using errors.Is and errors.As. They already do this same walk, and they also take into account the custom Is and As methods, which my Walk ignores.

Testing errors the way you should 🧪

We have already seen a bunch of loose tests. Now let’s put it all together in patterns that you can copy as is into your projects.

But first, the golden rule, and if you take only one thing from this section let it be this. Test the error’s contract, not its text.

What does that mean? That in your tests you should ask with errors.Is, errors.As and look at the error’s fields, and not do things like strings.Contains(err.Error(), "not found"). The message is for humans and changes all the time (someone fixes a typo, adds an id, changes the format…). The error chain, on the other hand, is the real API. If you compare strings, your tests break for things that don’t matter and don’t break for things that do. 😬

Table-driven tests

In Go it’s super common to write tests as a table, a slice of cases where each one says what input you give it and what you expect. Then a for goes through all of them. For errors it works great, because each case can declare what kind of failure it expects.

  • that there is no error,
  • that there is a specific sentinel (wantIs),
  • or that there is a specific type with a certain field (wantField).
func TestUpdateEmail(t *testing.T) {
	tests := []struct {
		name       string
		id         int
		email      string
		wantIs     error
		wantField  string
		wantNilErr bool
	}{
		{name: "ok", id: 1, email: "[email protected]", wantNilErr: true},
		{name: "empty email", id: 1, email: "", wantField: "email"},
		{name: "missing user", id: 99, email: "[email protected]", wantIs: ErrNotFound},
	}
	svc := NewService(NewStore())
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := svc.UpdateEmail(tt.id, tt.email)
			if tt.wantNilErr {
				if err != nil {
					t.Fatalf("unexpected err: %v", err)
				}
				return
			}
			if err == nil {
				t.Fatal("expected error")
			}
			if tt.wantIs != nil {
				assertErrorIs(t, err, tt.wantIs)
			}
			if tt.wantField != "" {
				var ve *ValidationError
				if !errors.As(err, &ve) {
					t.Fatalf("expected *ValidationError; err = %v", err)
				}
				if ve.Field != tt.wantField {
					t.Errorf("Field = %q; want %q", ve.Field, tt.wantField)
				}
			}
		})
	}
}

The good thing about this is that adding a new case is adding one line to the table. And since each case runs inside t.Run, if one fails you see exactly which one (TestUpdateEmail/empty_email) without the others stopping.

A helper so you don’t repeat yourself

If you find yourself writing the same errors.Is block in twenty tests, pull it out into a function. The key is t.Helper(), without that, when the test fails, Go tells you the failure is on the helper’s line (which is useless to you). With it, it points you to the line of the test that called it.

func assertErrorIs(t *testing.T, err, target error) {
	t.Helper()
	if !errors.Is(err, target) {
		t.Fatalf("errors.Is(%v, %v) = false", err, target)
	}
}

If you use testify, require.ErrorIs and require.ErrorAs do exactly this.

Example tests, documentation that runs

This is one of my favorite Go tricks. A function that starts with Example and ends with a // Output: comment is a test, Go runs it and compares what it prints with what you put in the comment. And it also shows up in the package documentation (go doc).

func ExampleService_GetUserByID() {
	_, err := NewService(NewStore()).GetUserByID(42)
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrNotFound))
	// Output:
	// service.GetUserByID id=42: store.FindUserByID id=42: not found
	// true
}

ā€œBut didn’t you just say not to compare text?ā€ Yes, haha. This is the only place where it does make sense, because here the goal is to document what the message looks like. If someone changes it, it’s fine for this test to warn.

Coverage

This is what the full run of everything in the post looks like.

$ go test -v -cover ./...
--- PASS: TestWrapKeepChan
--- PASS: TestWrapOpaque
--- PASS: TestErrorfConcreteTypes (3 subtests)
--- PASS: TestErrorsAs
--- PASS: TestNilIsNotWrapped
--- PASS: TestAsWithInterface (3 subtests)
--- PASS: TestCustomIs
--- PASS: TestIsByType
--- PASS: TestJoin
--- PASS: TestMultiplePercentW
--- PASS: TestWalk
--- PASS: TestWalkTree
--- PASS: TestUpdateEmail (3 subtests)
--- PASS: ExampleService_GetUserByID
coverage: 94.4% of statements

If you want to see which lines were left uncovered, this opens an HTML with the code painted in green and red.

go test -coverprofile=c.out && go tool cover -html=c.out

Almost always what shows up in red are if err != nil branches. That is, exactly the error paths nobody tested. Which is, ironically, where bugs tend to hide. šŸ›

Watch out for this, the most common mistakes āš ļø

We’re almost done with this post, which is by far one of the longest I’ve made, but before that I want to leave you a list of things I see (and have done šŸ™ˆ) all the time, to finish well.

What you should do āœ…

  • Add context that contributes. Good context says where it happened and with what data, store.FindUserByID id=42. Bad context says nothing, error, failed, something went wrong.
  • Lowercase and no final period. Messages are chained with : , so Store.Find failed.: Not Found. looks horrible. Better store.Find: not found.
  • %w or %v is a decision, not an oversight. Use %w when you want whoever calls you to be able to inspect the cause. Use %v on purpose when you want to hide an internal detail. For example, if your package uses Postgres underneath, maybe you don’t want your users to start depending on the driver’s errors. There %v protects you.
  • Sentinels for conditions, types for data. If whoever calls you only needs to know what happened (ā€œdoesn’t existā€, ā€œno permissionā€), a sentinel. If they need data (which field, which code), your own type.
  • One layer per boundary. Wrap when the error crosses from one layer of your app to another (store → service → handler), not on every line.

What you should NOT do āŒ

  • Compare strings. strings.Contains(err.Error(), "not found") works today and breaks tomorrow as soon as someone touches the message. I already said it, but I repeat it because it’s the most common one. šŸ˜…
  • Use == after wrapping. err == ErrNotFound stops working as soon as there is a layer on top. Always errors.Is.
  • Log and return the same error. If each layer does log.Println(err) and also return err, you end up with the same failure repeated four times in the logs and you don’t know which is which. Either you handle it (and log), or you return it. One or the other.
  • Filler context. fmt.Errorf("error: %w", err) adds nothing, it just makes the message longer. We already know it’s an error. 🄲
  • Wrapping a nil. This one is treacherous. If you do this.
return fmt.Errorf("something: %w", err) // and err is nil

it doesn’t return nil. It returns a real error, with the text something: %!w(<nil>). Your function just failed without anything bad having happened. That’s why it always goes inside an if err != nil.

In summary šŸŖ†

If I had to keep one thing from this whole post, it would be the Russian doll one.

  • %w puts one doll inside another without breaking it. %v takes a photo of it and throws the doll away.
  • errors.Is looks for a specific doll by opening all the layers for you.
  • errors.As looks for a doll of a certain type and gives it to you so you can read its data.
  • Unwrap and Is let you craft your own dolls that work just like the ones in the standard library.
  • errors.Join turns the doll into a basket with several dolls inside.

And the habit that will really save you headaches, every time you return an error that someone is going to handle, write a test with errors.Is or errors.As that proves it. If that test doesn’t exist, sooner or later someone is going to change a %w for a %v and nobody will find out until production. šŸ’„

I know this post was long and with quite a lot of code, but I think it’s worth having it all together. If you want to try it yourself, clone Rivaldito/go101, go into errWrap and run go test -v -cover ./....

In the next post we continue with more Go. šŸ‘‹

To read more šŸ“š

The song of the post šŸŽµ

I can say that I write software and related posts just to talk about music, hahaha.

I’M WAY TOO HAPPY, one of my favorite bands released a new album, and today’s music recommendation is the song they released as the first teaser of their album.

Marianne, by Fontaines D.C.

Listening to

Comments