How do errors.Is and errors.As search an error produced by errors.Join?
answer
- one child, or many children
- the chain stopped being a line
- depth-first, first match wins
- the single-result unwrap helper finds nothing here
basics
~20 sA joined error exposes its causes through an Unwrap() []error method, so the chain becomes a tree. errors.Is and errors.As walk that tree depth-first in order and stop at the first match. The single-result errors.Unwrap function returns nil for it.
solid answer
~40 sSingle-cause wrapping gives a linear chain via `Unwrap() error`. A value from `errors.Join` instead implements `Unwrap() []error`, so the structure is a tree: each node may have several children, and a child may itself be joined. `errors.Is` and `errors.As` understand both forms — at a multi-cause node they recurse into each child in order, depth-first, and return as soon as something matches, so `errors.Is(joined, ErrNotFound)` is true if any branch matches. The catch is the `errors.Unwrap` **function**, which only calls a method of the form `Unwrap() error`; it returns nil for a joined error, so hand-written unwrap loops silently see nothing. If you need the causes yourself, type-assert to `interface{ Unwrap() []error }`. A type should implement one unwrap form or the other, never both.
code
go · 12 linesvar ErrNotFound = errors.New("not found")
joined := errors.Join(
errors.New("network down"),
fmt.Errorf("fetch manifest: %w", ErrNotFound),
)
fmt.Println(errors.Is(joined, ErrNotFound))
// prints: true
fmt.Println(errors.Unwrap(joined) == nil)
// prints: truego deeper
Remember that you never walk error causes by hand: call errors.Is or errors.As and let them do the searching, whether the error came from one wrap or from several joined failures.
Be able to draw the difference between the single-child and multi-child unwrap methods, name the depth-first first-match order, and explain why the errors.Unwrap function returns nil for a joined value.
Show the diagnosis: a classification that quietly started defaulting after a package adopted multi-cause errors, traced to a hand-rolled unwrap loop, and fixed by moving to errors.Is or an explicit causes accessor.
Decide what your packages promise. Once a joined error crosses a boundary, callers will match against its causes, so which sentinels appear inside it becomes part of the contract you cannot quietly change.
## Chain versus tree Go's error inspection is defined by unwrapping. There are two unwrap shapes and they build different structures: - `Unwrap() error` — one parent, one child. Repeated wrapping with `fmt.Errorf("...: %w", err)` builds a **linked list**. - `Unwrap() []error` — one parent, many children. `errors.Join` returns a value with this method, and so does `fmt.Errorf` when the format string contains more than one `%w`. Repeated joining builds a **tree**. Both arrived in Go 1.20, and from that release `errors.Is` and `errors.As` understand both. ## How the search runs `errors.Is(err, target)` starts at `err` and, at every node it visits, does this in order: check whether the node equals the target; if the node has an `Is(error) bool` method, call it and accept a true result; then unwrap and repeat. At a node with `Unwrap() []error` it visits the children left to right, exploring each child's whole subtree before moving to the next — depth-first, pre-order — and returns true at the first match. `errors.As(err, &target)` walks exactly the same order, assigning the **first** node whose concrete type is assignable to the target. Two consequences follow directly: 1. **Matching is existential.** `errors.Is(joined, ErrNotFound)` is true when *any* cause matches, so you cannot conclude from a true result that every operation failed the same way, only that at least one did. If a caller needs to branch on "all of them were not-found", it must inspect the causes individually. 2. **Order decides which one you get.** With `errors.As`, a joined error containing two different `*PathError` values gives you whichever appears first in argument order. If you are collecting failures from a loop, that is the first item that failed, which is often reasonable, but it is a decision your accumulation order makes, not one the errors package makes for you. ## The trap: the errors.Unwrap function `errors.Unwrap(err)` is documented to call only a method of the form `Unwrap() error`. A joined error does not have that method — it has the slice form — so the function returns nil for it. Any hand-rolled loop of the shape ```go for e := err; e != nil; e = errors.Unwrap(e) { ... } ``` sees the joined error itself, then stops. It will never visit the causes, and it fails silently: no panic, no error, just a search that finds nothing. This is the single most common bug when a codebase that previously had only linear chains starts using `errors.Join`. The fix is to stop hand-rolling: use `errors.Is` and `errors.As`, which handle both shapes. Where you genuinely need the causes as values — to count them, to render them as a list, to classify each one — assert for the interface: ```go if m, ok := err.(interface{ Unwrap() []error }); ok { for _, cause := range m.Unwrap() { // ... } } ``` That anonymous interface is the idiomatic way to ask; the standard library does the same thing internally. Note that the slice you get back belongs to the error value, so treat it as read-only. ## Writing your own multi-cause type If you define a type carrying several causes, give it `Unwrap() []error` and nothing else in the unwrap family. Implementing **both** `Unwrap() error` and `Unwrap() []error` on one type is a mistake: the two methods cannot coexist meaningfully, the errors package's traversal only looks for one shape per node, and readers cannot tell which one is authoritative. Return the causes in a stable, meaningful order, and do not return a nil element inside the slice — the traversal will treat it as a node. ## Cost A deep or wide tree means the search visits every node until it matches, so an inspection over a joined error with thousands of causes is proportional to the number of causes. That is fine for the handful of failures a validation produces, and it is worth knowing about when the joined error is the accumulated output of a long run and a hot path calls `errors.Is` on it repeatedly. Match once, near the boundary, and carry the decision rather than re-walking the tree.
- A joined error contains two different `*fs.PathError` values. What does errors.As give you?The first one in traversal order — depth-first, left to right over the causes as you passed them. There is no ranking or preference; if the second one is the interesting one, `errors.As` will not find it, and you must walk the causes yourself and pick.
- Why is a hand-written `for e := err; e != nil; e = errors.Unwrap(e)` loop unsafe once a codebase adopts errors.Join?The `errors.Unwrap` function calls only an `Unwrap() error` method, which a joined error does not have, so the loop stops at the joined value and never visits the causes. It fails silently, and the classification it was doing quietly starts returning the default branch.
- Should a custom error type implement both `Unwrap() error` and `Unwrap() []error`?No. Pick the one that matches the value's shape: the single form when it wraps exactly one cause, the slice form when it carries several. Implementing both leaves the traversal and every human reader guessing which is authoritative.
- Does Go offer a generic alternative to errors.As for this traversal?Yes — Go 1.26 added `errors.AsType`, a generic form that returns the matched value rather than requiring a pointer target. It walks the same tree in the same order, so everything about traversal order and first-match semantics is unchanged.
saying these in an interview costs you the question
- Says errors.Is requires every cause to match the target
- Believes errors.Unwrap returns the first cause of a joined error
- Thinks errors.Is only follows a single linear chain
- Implements both unwrap methods on one error type
- Assumes errors.As picks the most specific cause rather than the first