errors.As returns true but the caller's target is zero-valued. How do you diagnose that in a proxy?
answer
- the boolean is a promise nothing verifies
- the first true ends the search
- print each link's %T before reading any method
- look for a branch that returns true and assigns nothing
- tests must assert the extracted fields, not the bool
basics
~20 sSome link's custom As method returned true without assigning through the target pointer. Because errors.As trusts that true and stops walking, the real cause below is never reached. Dump the chain and read the first link that declares As.
solid answer
~50 sThe symptom is specific to a custom `As` method: `errors.As` stops at the first link that returns true, so a method that reports a match without writing through the pointer hands the caller a nil or zero value and silently prevents deeper links from ever being consulted. To confirm it, walk the chain with a loop over `errors.Unwrap`, printing `%T` and the message for each link, and identify the outermost type that declares `As(any) bool`. Then read that method for the two usual defects: a branch that returns true without assigning, and a type switch matching on `*StatusError` when the caller's pointer arrives as `**StatusError`. The fix is to make `true` reachable only on the line after a successful assignment, and to add a test that asserts the extracted value's fields rather than just the boolean — the assertion everyone skips, which is exactly why this bug ships.
code
go · 3 linesfor e := err; e != nil; e = errors.Unwrap(e) {
fmt.Printf("%T: %v\n", e, e)
}go deeper
Be ready to say that errors.As writes the found error into the variable you pass by address, so a true result with an empty variable means something claimed a match it did not deliver.
Explain the mechanism: a custom As method's boolean is trusted, so a true from a branch that assigned nothing both empties the caller's variable and ends the search early.
Walk the diagnosis: dump the chain to find the outermost link declaring As, read that method for a true that assigns nothing or a switch on the wrong pointer depth, then prove the fix with a test that asserts a field.
Set the standard for the package: restrict custom matching methods to types that genuinely synthesise a view, and require every such method to ship with a field-level test, so the failure mode cannot be copied into the next error type someone adds.
## The symptom A caller of your proxy package writes the ordinary extraction: ```go var se *StatusError if errors.As(err, &se) { telemetry.Record(se.Status) // panics, or records 0 } ``` The branch is entered — so something matched — and yet `se` is nil, or points at a struct whose fields are all zero. A nil dereference in a metrics call is the usual way this surfaces, often only for one upstream failure mode, weeks after the code shipped. ## Why the value can be empty when the boolean is true `errors.As` searches the chain link by link. At each link it first tries direct assignability; failing that, it calls that link's optional `As(any) bool` method. The boolean is a *promise*: true means "I have written a usable value through the pointer you gave me". `errors.As` has no way to verify that promise, so it does the only sensible thing — returns true and stops. Two consequences, and both matter for the diagnosis: - The caller's variable is whatever it was, usually the zero value. - The search was cut short. Any deeper link that genuinely was a `*StatusError` is never reached, so the bug hides a correct answer that existed all along. The second point is why this presents as "errors.As is broken" rather than "my method is broken": the caller sees a true, so a lying method looks like a successful match. ## Step 1: see the chain Before reading any code, print what is actually in the chain. A five-line helper is enough, and it belongs in a scratch test rather than in production: ```go for e := err; e != nil; e = errors.Unwrap(e) { fmt.Printf("%T: %v\n", e, e) } ``` Running it against the failing input tells you the order of link types. The first line is the outermost error, and that is where the search starts — so the first type in the list that declares `As(any) bool` is the one whose promise `errors.As` believed. ## Step 2: read that method for two defects **Defect one: true without an assignment.** Usually the result of a refactor, or of writing the recogniser before the constructor: ```go func (e *ProxyError) As(target any) bool { if _, ok := target.(**StatusError); ok { return true // nothing was written through the pointer } return false } ``` The repair is structural, not cosmetic: make `true` unreachable except immediately after the assignment, so no future branch can be added that skips it. **Defect two: recognising too much.** A case that accepts a target the method cannot really fill — matching a wide interface pointer, or a `case **StatusError:` that assigns only when a field is set and returns true regardless. The same rule fixes it: return false whenever you cannot produce a value, and let the search continue. A third, opposite defect looks similar from outside and is worth ruling out in the same reading: a type switch on `*StatusError` instead of `**StatusError` never fires at all, so the method always returns false and `errors.As` reports no match. Zero value with true is defect one; no match at all is this one. ## Step 3: prove it, then prevent it Write the test against the real chain shape, and assert the *contents*: ```go var se *StatusError if !errors.As(err, &se) { t.Fatal("no match") } if se == nil || se.Status != 429 { t.Fatalf("got %+v, want status 429", se) } ``` A test that asserts only the boolean passes against the broken method. That is the single most useful review rule for a package that declares custom matching methods: every `As` test asserts a field. ## What does not help Switching the caller to the generic spelling added in Go 1.26 does not rescue you — it performs the same search, so the same method still decides the outcome. Nor does adding an `Is` method: `Is` answers a different question and is not consulted by `errors.As` at all. And a race detector run finds nothing, because there is no race; this is a correctness bug in a plain method. ## The maintainer's angle This defect is characteristic of a package that has accumulated error types over time. Each new type is added by someone matching the shape of the existing ones, and the optional methods get copied along with the struct. The durable fix is a boundary decision: only types that genuinely synthesise a view they do not hold should declare `As` at all, and each one needs a test that extracts and reads a field. Everything else should be found by plain type assignability, which cannot lie.
- Why does this bug also hide a correct match that exists deeper in the chain?Because `errors.As` returns as soon as any link reports true. The lying method is at the outermost link that declares `As`, so the search never reaches the deeper link that really is the requested type. Fixing the method usually makes the extraction start working with no change at the call site — good evidence you found the right cause.
- The same package has a type whose As method never seems to match at all. What do you check first?The type switch. `errors.As` passes the caller's pointer through, so extracting a `*StatusError` means the parameter holds `**StatusError`. A `case *StatusError:` compiles, never fires, and the method silently returns false forever. It is the mirror image of the lying-true bug and the second thing to read in any custom `As`.
- What single review rule would have stopped this from shipping?Require every test of a custom `As` method to assert a field of the extracted value, never just the boolean. That assertion fails against a method that returns true without assigning, and it also fails against one whose type switch never fires. It costs one line and covers both defects a hand-written matching method can have.
- Would running the failing path under the race detector help?No. There is no concurrency involved: a method returned true and wrote nothing, which is a plain correctness bug in sequential code. The race detector only reports unsynchronised accesses that actually occur at run time. The useful instruments here are a chain dump and a test that reads the extracted value.
A courier who signs for a parcel and hands you an empty box: the receipt is genuine, the delivery is not, and nobody downstream is asked again.
saying these in an interview costs you the question
- Blames errors.As itself rather than the type's As method
- Thinks errors.As verifies that the target was written
- Expects the search to continue after a true is returned
- Reaches for the race detector on a sequential correctness bug
- Tests only the boolean and never the extracted fields