Go — Typed nil: a Non-Nil Interface Holding a nil Pointer
Explain why a nil *T stored in an interface compares non-nil in Go, find the functions returning typed nils, and fix the boundary with explicit nil returns.
Symptoms
- An error check if err != nil unexpectedly passes (the code treats it as non-nil) or the value behaves as nil but the comparison says non-nil
- Calling a method on the "nil" value works in some code paths and panics in others
- fmt.Println(err) prints <nil> while err != nil is true — the visible contradiction
Possible causes
- A function declares an interface return type but returns a nil pointer of a concrete type
- The interface value then holds (type, value) = (*MyErr, nil) — a non-nil interface with a nil payload
- A helper wraps errors: return err where err is a typed nil, instead of returning nil
- Struct pointer methods with nil receivers succeed, so the nil flows deeper before exploding
Diagnosis steps
Print the contradiction
fmt.Printf("%v %T %v\n", err, err, err != nil)Interpretation
Output like <nil> *main.MyErr true is the entire diagnosis in one line: nil payload, concrete type present, non-nil interface.
Find the typed-nil returns
grep -Rn "func .*\) .*Err\|return .*nil" src/ | head; grep -Rn "var e \*.*Err\n.*return e" src/Interpretation
Functions declaring error or interface returns but handing back concrete-typed nils are the producers.
Assert nil-ness at the boundary
go test -run TestNilBoundary -v ./api/Interpretation
A test asserting err == nil for the empty case fails against typed nils and passes after the fix, pinning the contract.
Root cause
A Go interface value is a pair: a concrete type and a value. Only a nil interface has both parts empty. Storing a nil *MyErr produces (type=*MyErr, value=nil), which compares non-nil because the type word is set. Method calls dispatch through the type, which is why nil-receiver methods can succeed right up until they dereference.
Fix
Return literal nil at interface boundaries, check concrete nils before wrapping (if p == nil { return nil }), and cover the empty path in tests. Keep the discipline in helpers: convert, then return plain nil on absence.
Risk note
Silently tolerating typed nils with reflect-based nil checks papers over the contract violation and moves the failure deeper. Fix the producing function instead.
Verify the fix
Table-driven tests assert err == nil and value == nil for empty cases across every constructor and lookup returning interfaces.
Symptom walkthrough
One printf exposes the paradox completely:
var err error = (*MyErr)(nil)
fmt.Printf("%v | %T | non-nil? %v\n", err, err, err != nil)
// <nil> | *main.MyErr | non-nil? true
The interface prints as nil, reports its type as a pointer to MyErr, and compares as non-nil. All three are simultaneously true because an interface is a (type, value) pair: the type word is populated, and only interfaces with both words empty equal nil. The runtime path then behaves inconsistently — a method with a nil receiver can execute fine until it dereferences the nil, so the program works until the deepest call finally panics.
Fix in practice
The producing function is where the fix belongs:
func FindUser(id string) error {
var e *NotFoundError // stays nil when absent
// ...
return e // BUG: (*NotFoundError, nil) — non-nil interface
}
func FindUser(id string) error {
if !exists(id) {
return nil // literal nil: both words empty
}
return &NotFoundError{ID: id}
}
Guard every wrap point too — the classic leak is passing a typed nil through an error chain:
func Wrap(err error) error {
if err == nil {
return nil // without this, a typed nil survives wrapping
}
return fmt.Errorf("context: %w", err)
}
Nil-receiver methods that behave usefully (like (*MyErr).Error reading fields defensively) make the problem more confusing, not less: they let the typed nil travel deeper into the program before it detonates.