skip to content

How do errors.Is and errors.As walk the chain that fmt.Errorf's %w builds?

level: middleimportance: must knowfreq 70%

answer

  1. it is a linked list, not a lookup
  2. one function compares, one assigns
  3. each step is Unwrap, until Unwrap is missing
  4. one of them needs a destination pointer

basics

~20 s

Both call Unwrap repeatedly. errors.Is compares each error in the chain to the target with ==; errors.As tries to assign each one into the pointer target's type and returns true at the first that fits.

solid answer

~40 s

Wrapping with `%w` gives the returned error an `Unwrap() error` method, so errors form a linked chain. `errors.Is(err, target)` starts at `err` and at each level tests `==` against the target, then steps down through `Unwrap` until it reaches an error with no `Unwrap` method, returning false if it never matched. `errors.As(err, target)` does the same walk but tests assignability instead of equality: `target` must be a non-nil pointer to a type implementing `error` or to an interface type, and at the first level whose dynamic type fits, `As` stores that error through the pointer and returns true; a target of the wrong shape makes it panic. `errors.Unwrap(err)` exposes a single step and returns nil when there is no `Unwrap` method. This is why `err == ErrNotFound` fails once wrapped while `errors.Is` still succeeds.

code

go · 10 lines
go
var ErrNotFound = errors.New("record not found")

func demo() {
	inner := fmt.Errorf("repo: %w", ErrNotFound)
	outer := fmt.Errorf("service: %w", inner)

	_ = errors.Is(outer, ErrNotFound)       // true, after two Unwrap steps
	_ = errors.Unwrap(outer)                // inner
	_ = errors.Unwrap(errors.Unwrap(inner)) // nil: the sentinel has no Unwrap
}

go deeper

for a junior

Know that errors.Is is how you compare against a sentinel and errors.As is how you get hold of a concrete error type, and that both look deeper than the outermost error.

for a middle

Be ready to describe the loop out loud: test at this level, call Unwrap, repeat until there is nothing to unwrap. Say what errors.As demands of its target and what errors.Unwrap returns on its own.

for a senior

Show that a chain is only as deep as its weakest link, that one %v in the middle makes every errors.Is above it fail, and that you pin matching with a test at the package boundary rather than trusting the message.

for a principal

Frame the walk as the reason wrapping is a contract: anything reachable through Unwrap is something a consumer can build behaviour on, whether or not you meant to offer it.

## The chain is an ordinary linked list There is no registry and no runtime magic behind error wrapping. When `fmt.Errorf` sees `%w`, the value it returns carries a reference to the operand and exposes it through one method: ``` Unwrap() error ``` Any type of yours can do the same by declaring that method. So a wrapped error is just a node pointing at the next node, and the last node, typically a sentinel built with `errors.New`, has no `Unwrap` method at all. That is the end of the list. ## `errors.Unwrap`: one step `errors.Unwrap(err)` returns the result of `err`'s `Unwrap` method, or `nil` if `err` has no such method. It is a single step, not a traversal. Written as a loop it is the whole data structure: ``` for err != nil { fmt.Printf("%T: %v\n", err, err) err = errors.Unwrap(err) } ``` That loop is also the best debugging tool for wrapping problems, because it prints where the chain actually stops. ## `errors.Is`: equality along the chain `errors.Is(err, target)` answers "is this specific error value somewhere in the chain?". It walks from `err` downward and at each level compares the current error to `target` for equality, then unwraps and repeats. It returns true at the first match and false when the walk runs out. The classic use is a package-level sentinel: ``` var ErrNotFound = errors.New("record not found") ``` Each `errors.New` call produces a distinct value even for identical text, which is exactly what makes sentinel comparison meaningful: matching is by identity, never by message. `errors.Is` also gives a level a chance to answer for itself if that level's type defines its own `Is` method, but the default behaviour, and the one you rely on for sentinels, is plain equality. ## `errors.As`: type matching along the chain `errors.As(err, target)` answers "is there an error of this concrete type in the chain, and if so give it to me?". The walk is identical; the test is different. At each level it checks whether the error's dynamic type is assignable to the type `target` points at. On the first fit it assigns through the pointer and returns true, which is why the argument must be a pointer at all: `As` has to have somewhere to put the value. ``` var pathErr *fs.PathError if errors.As(err, &pathErr) { // pathErr.Op, pathErr.Path and pathErr.Err are now usable } ``` `target` must be a non-nil pointer to a type implementing `error` or to an interface type. Anything else, including passing the value rather than its address, makes `errors.As` panic. `go vet`'s `errorsas` check catches the common form of that mistake before it ships. Use `errors.As` rather than a type assertion. `err.(*fs.PathError)` only inspects the outermost error, so it starts failing the moment any layer wraps the error with `%w`, which is precisely the change you want to be free to make. ## Where the walk stops The walk ends at the first error without an `Unwrap` method. Two very different things produce that ending: 1. You reached the bottom, a sentinel or a leaf error. This is normal. 2. Some layer in the middle formatted its cause with `%v` instead of `%w`. The chain was cut there, and everything below it is unreachable even though its text is still in the message. From the caller's side those two look identical: `errors.Is` returns false either way. That asymmetry is why wrapping discipline matters through every layer of a return path, not just at the top and the bottom. ## Cost and practical notes - The walk is O(depth) with depth typically under five, so cost is not a consideration on an error path. - `errors.Is` is the right way to compare against a sentinel; `==` is only correct when you know nothing wrapped the error, which is a fragile assumption in any package with callers. - Both functions accept a nil error and simply report no match, so you do not need a nil guard before calling them. - Recent Go adds `errors.AsType`, a generic form of `errors.As` that takes the wanted type as a type parameter and returns the matched error together with a bool, instead of requiring you to declare a variable and pass its address. It performs the same walk.

  • Why does errors.As require a pointer as its second argument?
    Because it hands you the value it finds. `As` needs a destination to assign the matched error into, so you pass the address of your variable. The target must be a non-nil pointer to a type implementing `error` or to an interface type; anything else panics. `go vet`'s errorsas check flags the usual mistake of passing the value instead of its address.
  • When exactly does the errors.Is walk stop?
    When the current error equals the target, or when `Unwrap` has nothing to return, which is the case for a sentinel made with `errors.New`. Critically, it also stops early if a layer in the middle formatted its cause with `%v`: the chain was cut there, so `errors.Is` reports false even though the message text still names the cause.
  • Why prefer errors.As over a type assertion like err.(*fs.PathError)?
    A type assertion only looks at the outermost error. It works until someone adds a `fmt.Errorf("...: %w", err)` in a layer between you and the source, and then silently stops matching. `errors.As` performs the same type test at every level of the chain, so it keeps working as callers add context.
  • How does errors.AsType differ from errors.As?
    It is generic: you name the target type as a type parameter and it returns the matched error value along with a bool, instead of requiring you to declare a variable first and pass its address. The chain walk is the same; what goes away is the declare-then-address dance and the class of panics that comes from an ill-shaped target.

saying these in an interview costs you the question

  • Says errors.Is compares error messages as strings
  • Passes a value instead of a pointer to errors.As
  • Thinks errors.Is only inspects the outermost error
  • Uses a type assertion on an error that callers may wrap
  • Believes errors.Unwrap walks the whole chain in one call