← News & analysis

GO DEEP DIVE · ERRORS · 11 MIN READ

Typed nil errors in Go: return, wrap and match a reliable contract

Explain typed nil, pointer versus value errors and errors.As with runnable examples. Design stable error boundaries and regression tests that catch false failures.

Edition: · Practical guide. Added to the archive on the publication date above. Sources and worked examples below.

Conceptual illustration for Typed nil errors in Go: return, wrap and match a reliable contract
Conceptual illustration for Typed nil errors in Go: return, wrap and match a reliable contract AI-generated conceptual illustration.

The surprising success value that still looks like failure

A function completes successfully, returns a variable that contains a nil pointer, and its caller enters the error branch. The symptom looks contradictory only if we treat an interface as an untyped box of bits. In Go, an interface can hold a concrete type together with a value of that type. A typed nil pointer inside an error is therefore different from an interface with no dynamic type and no value.

This guide follows that distinction through constructors, wrapping, errors.As, HTTP boundaries and regression tests. It is deliberately broader than a trick question about err != nil: the practical goal is an error API that callers can classify without inspecting strings or understanding the producer's internal pointer choices. The nil interface lesson introduces the core concept, while the gopls 0.24 article connects consistent error types to current tooling.

See the dynamic type in a complete program

The following program never calls a method through the nil pointer. It prints only the interface's nil comparison and dynamic type, so the example does not rely on whether an Error method happens to tolerate a nil receiver.

go
package main

import "fmt"

type Failure struct{ Message string }

func (f *Failure) Error() string { return f.Message }

func misleading() error {
    var failure *Failure
    return failure
}

func main() {
    err := misleading()
    fmt.Println(err == nil)
    fmt.Printf("%T\n", err)
}

The output is false and *main.Failure. The returned interface has a concrete pointer type even though the pointer value is nil. A normal success return should instead return the nil interface explicitly. That is a local, understandable contract: if no failure occurred, return nil; otherwise construct a valid error value.

Do not fix this by teaching every caller to reflect over every possible nil-like value. That moves a producer bug into all consumers, obscures the contract, and can accidentally treat an intentionally meaningful typed value as success. Repair the constructor or adapter that introduced the inconsistent return. At a third-party boundary, isolate any compatibility workaround and test it against the specific library behavior you actually observed.

Make success and failure construction explicit

The safest repair is usually small. Keep the concrete pointer local to the failure branch, or return nil before constructing it. This makes the absence of an error visible to both the compiler's reader and the next maintainer.

go
package main

import "fmt"

type ValidationError struct{ Field string }

func (e *ValidationError) Error() string { return "invalid " + e.Field }

func validate(name string) error {
    if name == "" {
        return &ValidationError{Field: "name"}
    }
    return nil
}

func main() {
    fmt.Println(validate("Ada") == nil)
    fmt.Println(validate("") != nil)
}

Both lines print true. The function's return type remains error, allowing callers to depend on behavior rather than construction details. The concrete error can still carry structured information for callers that need it. This is different from returning a typed nil as a signal: callers can now rely on the ordinary nil check before attempting classification.

A useful unit test includes the success case even when the bug report concerns a failure. Error-focused tests often exercise only invalid inputs and miss the accidental typed nil on the successful path. Add the empty, valid and partially valid cases that the domain actually distinguishes. Test the API contract at its return boundary rather than testing only the helper that constructs the pointer.

Return → Wrap → Match → Test. Return nil for success, preserve intended classification, and test pointer/value error forms.
Return → Wrap → Match → Test. Return nil for success, preserve intended classification, and test pointer/value error forms. AI-generated conceptual illustration.

Pointer and value errors are distinct matching targets

Even with correct nil behavior, an error API can become inconsistent if some branches return a value and others return a pointer to that value. A method with a value receiver can make both forms implement error, but that does not make their dynamic types identical. Classification through errors.As depends on assignability to the requested target type.

go
package main

import (
    "errors"
    "fmt"
)

type Problem struct{ Code string }

func (p Problem) Error() string { return p.Code }

func main() {
    wrapped := fmt.Errorf("save record: %w", &Problem{Code: "conflict"})
    var pointer *Problem
    var value Problem
    fmt.Println(errors.As(wrapped, &pointer))
    fmt.Println(errors.As(wrapped, &value))
}

This prints true and false. The first target requests a *Problem; the second requests a Problem. The additional address operator supplies a writable target to errors.As, which is easy to confuse with the dynamic pointer type being requested. Read the variable declaration first, then the call. This avoids memorizing a misleading rule about how many ampersands a particular example contains.

Choose one exported construction style where practical, document it, and use tests to keep return branches consistent. A value type can be appropriate for small immutable errors. A pointer type can be appropriate for larger structured errors or identity-sensitive cases. Consistency and a deliberate matching contract matter more than choosing the same style for every package in the ecosystem.

Wrapping preserves a chain only when you ask for it

Adding context is useful: “read settings” tells an operator more than “permission denied.” But formatting a message and preserving the original error are separate choices. With %w, callers can inspect the wrapped chain through standard error helpers. With ordinary string formatting, the message may look similar while the structural relationship is gone.

Avoid turning every implementation detail into a permanent public promise. If callers are expected to recognize a sentinel or a specific typed error, wrapping it makes that classification part of the API. If the lower-level error belongs to a replaceable implementation, consider translating it into a stable domain error instead. The decision belongs at the boundary where technical details acquire business meaning.

Our error wrapping lesson and clean error boundaries examine these choices from different directions. When reviewing an adapter, ask which failures a caller needs to act on: retry, reject input, report absence, or stop. A stable domain classification is often more useful than exposing the exact storage driver's error type everywhere.

errors.Is and errors.As answer different questions

Use errors.Is when the question concerns a recognized error identity or an explicitly supported matching rule. Use errors.As when the question concerns a type and its structured information. Neither operation should be replaced with searching the error message for a word such as “timeout.” Messages are written for people and can change without altering the intended classification.

A typed error might include an operation, a field name, a retry hint or a code. Keep those fields meaningful and safe to expose. A database query containing credentials, raw user input or an internal filesystem path should not automatically become a client-visible string. Logging and user-facing responses are different consumers even when both start from the same error value.

If you implement custom matching methods, test the behavior callers will see through wrappers. A complicated matching rule can be harder to reason about than a simple exported sentinel or small classification interface. Prefer the least surprising contract that supports the domain. The standard errors reference is the authority for helper behavior; the architectural choice of what your package exposes remains yours.

A closer look at the workflow: errors.is and errors.as answer different questions
A closer look at the workflow: errors.is and errors.as answer different questions AI-generated conceptual illustration.

Translate at the HTTP boundary without losing diagnostics

Consider a repository that reports a record conflict. The service layer may translate a driver-specific condition into a domain conflict error. The HTTP handler can then produce a stable status and public error code while logs retain request context and an internal diagnostic chain. This avoids coupling every handler to a specific database driver's concrete error types.

The same design helps testing. Repository tests verify the translation. Service tests verify the business outcome. Handler tests verify the response contract. You do not need to assert the exact spelling of every internal error message at every layer. Instead, assert the public status or code where it becomes public, and assert structural classification where control flow depends on it.

Cancellation deserves its own branch. A request ending because its context was cancelled is not automatically evidence of a broken server or a retryable storage failure. Preserve enough information for the boundary to distinguish expected cancellation from a new operational incident. Our HTTP connection analysis complements this discussion with deadlines and response ownership.

Table tests should include the boring successful case

Build a matrix covering valid input, invalid input, wrapped failures and classification after one or more adapters. For each case, state whether the expected result is nil, a sentinel match, or a typed match. This keeps tests close to observable behavior and prevents a formatting change from breaking unrelated tests.

A second matrix can check constructor consistency: every branch that reports the same exported error category should use the documented pointer or value form. This is especially useful when refactoring several return statements or accepting an automated rewrite. The new ptrtoerror analyzer in gopls is a useful additional signal, but a diagnostic is not a substitute for the contract test.

Test an Error method's nil-receiver behavior only if you intentionally support that behavior. Making the method return a friendly message for a nil receiver can prevent a panic during formatting, but it does not make the interface itself nil. Those are separate properties. A test that confirms graceful formatting must not be mistaken for a test that confirms a successful function returns no error.

Keep the explanation useful in interviews and reviews

A strong interview answer starts with the two parts of an interface value, walks through the concrete program, and proposes a repair at the producer. It then explains why generic reflection at every call site is the wrong default. A stronger answer follows the error across wrapping and asks which classifications the API deliberately preserves.

For a review, draw four steps: construct, return, wrap, classify. At each step write the actual dynamic type or the expected matching contract. That small exercise catches accidental pointer/value changes, missing %w and typed nil returns without requiring a large conceptual diagram. It also gives an agent a narrow, verifiable task: inspect these branches, propose a consistent contract, and run the focused tests.

The larger lesson is that errors are part of data design. They carry absence, identity, type and context, and each of those can affect control flow. A readable message is valuable, but a reliable API requires more than text. Continue with the error unit, interface unit or interview collection to practise explaining these boundaries with your own examples.

Aggregate failures without discarding the useful cause

Some operations can fail in more than one place. A primary operation can fail and its cleanup can fail too, or several independent tasks can each produce an error. Go supports joined errors, and the standard matching helpers can inspect an error tree. This gives an application a way to preserve multiple causes without flattening them into one unstructured message. The Go 1.20 release notes describe the addition; the operational policy remains a decision for your code.

Do not interpret an aggregate as permission to retry everything indiscriminately. A request may have partially succeeded, and retrying all components can repeat a side effect. Record which component failed and whether an outcome is known, unknown or safely repeatable. The caller needs that information to choose recovery. A joined diagnostic is useful evidence, but it does not invent a transaction or an idempotency guarantee around several operations.

For classification tests, include a wrapped aggregate containing the cause callers are supposed to recognize. If several children could match the same type, decide whether using the first matching result is sufficient for the API. If the caller needs every individual failure, provide an explicit result model or documented traversal rather than assuming one extraction represents the whole operation. This is especially relevant for batch tools, where a single overall error may coexist with per-item outcomes.

Keep success behavior explicit here too. An API that reports no failures should return its documented success value, not a confusing typed wrapper around an absent internal result. The original nil-interface rule still applies even when the surrounding failure representation becomes more sophisticated.

Migrate an exported error contract deliberately

Suppose a package has already shipped inconsistent value and pointer returns. First inventory callers that classify those errors, including wrappers, examples and external integrations you maintain. Next choose the intended public construction style and document the matching contract. Then write tests at the producer boundary before changing every return statement. This sequence gives reviewers a stable behavioral target instead of a diff justified only by an analyzer warning.

Compatibility may require a transition period. An adapter can translate older forms into a stable domain classification while the underlying package is repaired. Keep that translation local and precise; do not add reflection-based nil filtering throughout the codebase. If the error type is externally consumed, explain the change in release notes and assess whether it belongs in a compatibility-sensitive release. A smaller internal package gives you more freedom, but tests should still protect its callers.

After the migration, remove obsolete branches and update examples so new users do not learn the inconsistent form. Check the successful path, one direct failure and one wrapped failure at minimum. Review log messages separately from public codes to avoid leaking implementation detail while improving classification. The result should be easier to explain in one sentence: success is nil, this category has one documented representation, and callers can recognize it through the supported helper. That sentence is a stronger maintenance tool than a collection of special cases that merely makes today's tests pass.

Primary sources & editorial method

We checked these primary references on 23 October 2026. Release facts are attributed; recommended workflows and examples are GopherForge analysis. Illustrations are conceptual, not vendor screenshots or benchmarks.