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.
%vtakes the error, pulls out its text and pastes it into the new message. The original error disappears, what remains is just a string.%wdoes 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 likeerrors.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.Isasks, is this specific error? (compares values)errors.Asasks, 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
veinstead of&ve.errors.Asdoes apanic, because the destination has to be a pointer that is notnil. - The wrong mold. If your function returns
&ValidationError{}(pointer) but you declarevar ve ValidationError(without*),errors.Asalso does apanic. SinceError()is defined on the pointer, plainValidationErrordoesnāt even fulfill theerrorinterface. The good news,go vet(which runs by itself withgo 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 fromfmt.Errorf, so it fails.errors.Aslooks 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
ifworks thanks to ourIsmethod, there is noErrNotFoundinside, but the 404 counts as one. - The second works thanks to
Unwrap, theno rowscause is still down there. - The third is the good old
errors.Astaking out the*QueryErrorto 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.
- Is this error
==to the one Iām looking for? If so,true. - Does it have an
Is(error) boolmethod and does it returntrue? If so,true. - 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.Errorfwith more than one%win 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.
- There is a different
Unwrap. Multiple errors donāt haveUnwrap() error, they haveUnwrap() []error(returns a slice). Thatās why in the test I do a type assertion tointerface{ Unwrap() []error }to be able to count how many errors are inside. errors.Unwrapreturnsnil. This confuses a lot of people.errors.Unwraponly knows how to callUnwrap() error(the single-error one), so with a basket of errors it finds nothing and returnsnil. To see the branches, use the type assertion above.IsandAsdo walk everything.errors.Isanderrors.Asunderstand both forms ofUnwrapand look inside each branch. Thatās whyDoubleWrapanswers yes to bothErrNotFoundandErrUnauthorized.Asreturns the first one it finds. The walk is depth first and in order, so inTestJoinit returns the error of theidfield (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%wone and our types).Unwrap() []error, several dolls inside (theerrors.Joinone 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
:, soStore.Find failed.: Not Found.looks horrible. Betterstore.Find: not found. %wor%vis a decision, not an oversight. Use%wwhen you want whoever calls you to be able to inspect the cause. Use%von 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%vprotects 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 == ErrNotFoundstops working as soon as there is a layer on top. Alwayserrors.Is. - Log and return the same error. If each layer does
log.Println(err)and alsoreturn 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.
%wputs one doll inside another without breaking it.%vtakes a photo of it and throws the doll away.errors.Islooks for a specific doll by opening all the layers for you.errors.Aslooks for a doll of a certain type and gives it to you so you can read its data.UnwrapandIslet you craft your own dolls that work just like the ones in the standard library.errors.Jointurns 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 š
- Documentation of the errors package
- Working with Errors in Go 1.13, from the official Go blog, where
%wwas introduced - Go 1.20 release notes, where multiple errors arrived
- Error Wrapping with Goās Standard Library, by Alexander Obregon, which gave me the idea to get into the guts of
%wand to use interfaces witherrors.As - Source code of fmt.Errorf, for the curious
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.
Comments