← Debugging Guides

阅读中文版本

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.

intermediatedebugginggointerfacesnil

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.