errors.As never finds the cause your custom error type stores in a field. Why?
answer
- a field is not a link
- the walk needs one specific method
- errors.As descends by calling something
- Unwrap() error is what it calls
- check %w versus %v one layer up
basics
~20 sBecause storing a cause in a field does not link it into the error chain. errors.As only descends by calling an Unwrap() error method, so a type that keeps its cause in a field without that method is where the search stops.
solid answer
~50 s`errors.As` walks the chain by calling `Unwrap() error` at each step. A custom type that keeps its cause in a field, say `LoadError{Path string; Err error}`, is opaque to that walk unless you add `func (e *LoadError) Unwrap() error { return e.Err }`. The bug hides well, because `Error()` usually interpolates `e.Err.Error()`, so the log line shows the cause and only the *matching* fails: the caller's `errors.As` for the underlying SDK error returns false, and the retry or the 404 mapping that depended on it silently never happens. The same break happens one layer up if an intervening wrapper formatted the cause with `%v` instead of `%w`. Diagnose it by walking the chain with `errors.Unwrap` in a test, then keep it from regressing with a boundary test that asserts `errors.As` finds the type through the exported function.
code
go · 15 linestype LoadError struct {
Path string
Err error
}
func (e *LoadError) Error() string {
if e.Err == nil {
return "load " + e.Path
}
return "load " + e.Path + ": " + e.Err.Error()
}
// Without this method the chain ends at *LoadError and e.Err is invisible
// to errors.As, even though the message still prints it.
func (e *LoadError) Unwrap() error { return e.Err }go deeper
Remember that errors.As only sees what Unwrap exposes. If your type keeps a cause, give it func (e *T) Unwrap() error returning that field, and use %w rather than %v when adding context.
Explain the traversal — test, then errors.Unwrap, then repeat — and why a missing Unwrap or a %v produces a false result while leaving the printed message completely unchanged.
Show the diagnosis, not just the fix: print %T for every link to find where the walk stops, identify whether the break is the type or a formatting verb, and add a boundary test so it cannot silently return.
Treat what a package wraps as part of its published contract: decide which internal errors callers may match on, which get flattened at the boundary, and how that promise is kept stable as dependencies change.
## What "the chain" actually is Go has no ambient stack of causes. An error chain exists only because each error value holds the next one and exposes it through a method: ```go Unwrap() error ``` `errors.As` starts at the error you handed it, tests it against the target, and then — if there was no match — calls `errors.Unwrap` on it to get the next one and repeats. `errors.Unwrap` returns the result of the error's own `Unwrap() error` method, or `nil` if it has none. `nil` ends the walk. So the answer to "why does `errors.As` not find my cause" is almost always: the walk stopped one layer above it. There are two places that happens. ## Break one: your type stores the cause but does not expose it ```go type LoadError struct { Path string Err error } func (e *LoadError) Error() string { return "load " + e.Path + ": " + e.Err.Error() } ``` This looks complete and is not. The field `Err` is just a field; the runtime attaches no meaning to its name. `errors.As(err, &sdkErr)` tests `*LoadError`, finds no `Unwrap`, and returns `false` — even though the value is sitting right there in the struct. The fix is one method: ```go func (e *LoadError) Unwrap() error { return e.Err } ``` What makes this bug expensive is that the *message* is fine. Because `Error()` interpolates `e.Err.Error()`, every log line shows the underlying failure, so a reader assumes the chain is intact. Only the programmatic branch is broken: the retry that should have fired on a transient upstream failure does not, the 404 that should have been mapped from a not-found error comes back as a 500, and none of it appears in the logs as anything but the original message. This is a very common defect in generated packages and in adapter layers written by the person integrating a third-party SDK, where the wrapper type is written once, mechanically, and the `Unwrap` method is simply forgotten. ## Break two: an intervening layer flattened the cause `fmt.Errorf("loading %s: %v", path, err)` renders the cause into text and returns a plain error with no link to the original. `fmt.Errorf("loading %s: %w", path, err)` returns a value that wraps it and has an `Unwrap` method. The two produce **identical message strings**, which is exactly why this survives code review. If a chain used to work and stopped, a `%w` that became a `%v` in a refactor is the first thing to check. ## How to diagnose it Write the walk out by hand in a test or a scratch program: ```go for e := err; e != nil; e = errors.Unwrap(e) { fmt.Printf("%T: %v\n", e, e) } ``` The `%T` column is the whole diagnosis. The chain prints down to the last type that has an `Unwrap` method, and the layer you expected to see next is missing. That immediately tells you which type or which format verb to fix, instead of guessing. ## Trees, not just chains An error can wrap more than one cause: `errors.Join` and a type with `Unwrap() []error` produce an error *tree*, and `errors.As` traverses it depth-first, so a match in any branch is found. That is why an aggregate error from a batch validation still lets a caller pull out one typed failure. Both were added in Go 1.20; before that, only the single `Unwrap() error` form existed. ## Two details worth getting right * **A nil cause.** `Unwrap` returning `nil` is correct and simply ends the chain, so a type whose `Err` field is sometimes unset needs no special handling in `Unwrap`. Its `Error()` method does — `e.Err.Error()` on a nil `Err` panics with a nil dereference, at exactly the moment you are trying to report a different failure. * **Wrapping is a contract.** Once a package wraps its cause, callers write `errors.As` against the inner type, and removing or changing what you wrap is a breaking change even though no signature moved. Decide deliberately which internal errors are part of your public surface and which should be flattened to an opaque message on the way out. ## Keeping it from regressing A unit test on the internal type is not enough, because the break is usually in a layer above it. Assert through the exported entry point: call the public function with an input that fails at the bottom, and assert `errors.As` recovers the type the documentation promises. That test fails the moment any layer between the two stops wrapping.
- An intervening layer formatted the cause with %v instead of %w. What breaks?The chain ends at that layer. `%v` renders the cause into text and returns a plain error with no `Unwrap` method, so everything below it becomes unreachable to `errors.As`. The message string is identical to the `%w` version, which is why the regression passes review and only shows up as a branch that stopped firing.
- How do you keep a broken chain from regressing after you fix it?Test through the exported entry point, not the internal type: drive an input that fails at the bottom and assert `errors.As` recovers the type your documentation promises. That test fails the moment any layer in between stops wrapping, which a unit test on the wrapper type alone would never notice.
- What if one error needs to carry several causes?Use `errors.Join`, or give your type an `Unwrap() []error` method instead of `Unwrap() error`. Both produce an error tree that `errors.As` traverses depth-first, so a caller can still pull out a typed failure from any branch. Both forms date from Go 1.20.
- Should every wrapper type expose its cause?No — wrapping is a public contract. Once callers match on an inner type, changing what you wrap breaks them without any signature moving. Wrap deliberately where you want callers to branch on the cause, and flatten to an opaque message at boundaries where the internal error should not become API.
Unwrap is the link between one chain link and the next. Putting the cause in a struct field without it is like laying two links side by side and expecting the chain to hold: the pieces are present, the connection is not.
saying these in an interview costs you the question
- Thinks a field named Err is unwrapped automatically
- Expects errors.As to reflect over struct fields
- Says the message showing the cause proves the chain is intact
- Adds Cause() error and expects errors.As to call it
- Uses %v to add context and assumes matching still works