A helper returns a nil *ValidationError as an error and the caller's check fires - what is the fix?
answer
- look at the helper's result type
- an interface remembers the type it holds
- the success path returned something after all
- build the value only in the failure branch
- fix the producer, not every caller
basics
~20 sDeclare the helper's result type as error, not *ValidationError, and return a literal nil on success. A concrete nil pointer stored in an error is an interface that carries a type, so it never compares equal to nil and every successful call looks like a failure.
solid answer
~50 sThe bug is in the helper's signature, not the caller's check. When a function is declared to return `*ValidationError` and the caller assigns that result to an `error`, the interface value records the concrete type even though the pointer inside is nil - so `err != nil` is true on every call, and a wizard that validates each field reports success as failure. The fix is to declare the helper as returning `error` and to `return nil` literally on the success path, constructing and returning the concrete value only inside the failure branch. If the concrete type is genuinely useful inside the package, keep it as the type of a *local* variable, never as the result type that escapes to callers. The same trap applies to struct fields and named results typed as a concrete error type, and the standard `go vet` suite has no check for it, so it is a review rule.
code
go · 14 linestype ValidationError struct{ Field string }
func (e *ValidationError) Error() string { return e.Field + " is invalid" }
func validateEmail(s string) *ValidationError { // concrete result type
if strings.Contains(s, "@") {
return nil
}
return &ValidationError{Field: "email"}
}
func runStep(s string) error {
return validateEmail(s) // non-nil even when validateEmail returned nil
}go deeper
Remember the symptom and the rule: functions return error, not a concrete error pointer type, and the success path returns a plain nil.
Explain why the conversion happens at the return statement and be able to rewrite the helper so the concrete value is only ever built inside the failure branch.
Demonstrate the diagnosis end to end: reproduce it, read the interface's two halves in a debugger, then name every variant - named results, struct fields, a concretely typed local - that reintroduces it.
Make it a standard the team does not relitigate: a signature convention plus the happy-path test that pins it, since nothing in the toolchain reports this and it fails silently in production.
## What the symptom looks like An interactive setup wizard walks the user through fields one at a time - hostname, port, email - and each step calls a validator. The validator was written like this: ```go func validateEmail(s string) *ValidationError { if strings.Contains(s, "@") { return nil } return &ValidationError{Field: "email"} } ``` and the step runner like this: ```go func runStep(s string) error { return validateEmail(s) } ``` Every step now fails. A perfectly valid address prints "step failed" with no message, or worse, the program prints the error and panics dereferencing it. The validator did return nil, the caller did check `err != nil`, and the check still fired. ## Why (stated, not re-derived) An `error` variable is an interface value, and an interface value is only nil when it holds no concrete type at all. Converting a `*ValidationError` - even a nil one - into an `error` produces an interface that names `*ValidationError` and carries a nil pointer. It is non-nil by definition. In a debugger this is unmissable once you know to look: stop on the caller's `err`, expand it, and you see a type word reading `*main.ValidationError` beside a value word of `0x0`. The two halves disagree, and the comparison follows the first one. ## The fix is in the signature The conversion happens because the helper's *declared result type* is concrete. Remove that and the trap cannot occur: ```go func validateEmail(s string) error { if !strings.Contains(s, "@") { return &ValidationError{Field: "email"} } return nil } ``` Now the success path returns a literal `nil` of interface type, and the only value that ever reaches an `error` variable is one that was actually constructed. Note the shape: the concrete value is built *inside* the failure branch, and the last statement is a bare `return nil`. That ordering is not cosmetic - it is what makes the bug unrepresentable. ## The variants a reviewer should watch for Changing the obvious signature is not enough on its own, because the same conversion can happen anywhere a concrete error type is assigned into an interface. In a pull request, look for: - **Any function whose result type is a concrete error type.** `func check(...) *ValidationError` is the headline case, and it is worth treating as a blanket rule: functions return `error`, full stop. - **A named result of concrete type.** `func check(...) (e *ValidationError)` with a bare `return` at the end has the same problem the moment the value is adapted to `error`. - **A struct field or slice element typed concretely.** `type stepResult struct { err *ValidationError }` looks harmless until someone writes `return r.err` from a function returning `error`. - **A local declared with `var e *ValidationError`** that is returned at the end of a function whose result type is `error`. This is the sneakiest form, because the signature looks correct. - **An interface-typed value passed on.** Once the value is already an `error`, passing it along is safe; the danger is only at the concrete-to-interface boundary. ## What is *not* the fix Several tempting repairs make things worse: - **Changing the receiver on `Error()`** from pointer to value changes nothing about the trap; it only changes which types satisfy the interface. - **Making callers type-assert before checking** pushes the producer's mistake onto every caller, forever, and it will be forgotten at the one call site that matters. - **Returning an empty `&ValidationError{}` instead of nil** turns "always fails" into "always fails with an empty message", which is harder to spot. - **Comparing `err.Error() == ""`** builds a second, string-based error protocol on top of a broken one. ## Why review has to catch it This compiles cleanly, and the standard `go vet` suite has no analyser for it. There is no runtime warning either - the program simply behaves as though everything failed. It is exactly the class of defect that survives unit tests written against the same helper, because a test that asserts "invalid input produces an error" passes; only a test asserting that *valid* input produces a nil error catches it. That test is cheap and worth writing for any validator. ## The rule to carry away A function's error result is declared `error`. The concrete type is an implementation detail of the failure path: construct it where the failure happens, return it as `error`, and let callers recover the concrete type only through the machinery designed for that. Anything else exports the risk that a successful call looks like a failed one - and the wizard that tells a user their correct email is invalid is the friendliest possible version of that bug. In an RPC handler or a payment path, it is a silent outage.
- How would you catch this in code review, before it ships?Scan result types: any function whose error result is a concrete type is suspect, as is a named result or struct field typed `*SomeError`, and a `var e *SomeError` returned at the end of a function declared to return `error`. Treating "functions return error" as a blanket rule removes the judgment call entirely.
- Does go vet or the compiler warn about this?No. The code is well-typed and the standard `go vet` suite has no analyser for it, so nothing fires at build time and nothing fires at runtime either - the program just behaves as though every call failed. It is caught by the review rule above, or by a test asserting that valid input yields a nil error.
- The concrete type really is useful inside the package. How do you keep it?Keep it as the type of local variables and of the value you construct, and let it cross the function boundary only as `error`. Inside the package you can still build, inspect and pass `*ValidationError` freely; the discipline is only about what the signature promises to callers.
- What test would have failed on the broken version?One asserting the happy path: call the validator with a valid address and require the returned error to be nil. Tests that only check that bad input produces an error pass on the broken code, which is why validator tests should always pin the success case too.
An empty envelope with a return address printed on it is still an envelope. The caller checks whether an envelope arrived, not whether there was a letter inside.
saying these in an interview costs you the question
- Says a nil pointer stored in an error is nil
- Blames the caller's err check instead of the signature
- Fixes it by comparing err.Error() to an empty string
- Claims go vet or the compiler reports it
- Keeps the concrete result type and documents the trap