Error Handling · Lesson 03

Sentinel, Type, Opaque

Three ways to let a caller tell one failure from another — and the coupling each one buys with. This is the representation axis from lesson 02, followed all the way down to a design decision you make in every package you write.


The question

A function failed. Sometimes the caller only needs to know that — log it, wrap it, hand it upward. But sometimes the caller needs to discriminate: retry on a timeout, return 404 on a missing row, fall back to defaults on a missing file. Discrimination requires the failure to be distinguishable, and every way of making it distinguishable exports something from your package into your caller's code.

Dave Cheney named the three strategies in 2016, in the context of Go. The names have since become the standard vocabulary for the design space in any language with errors‑as‑values.

1 · Sentinel errors

A predeclared value, compared for identity.

// go
var ErrNotFound = errors.New("not found")

if err == store.ErrNotFound { … }   // identity comparison

Familiar instances: io.EOF, sql.ErrNoRows, C's ENOENT, Rust's io::ErrorKind::NotFound.

Cheney: “using sentinel values is the least flexible error handling strategy.” Three costs, in increasing order of how much they hurt:

There is a fourth, worse variant: the in‑band sentinel, where the failure is signalled with a value from the success type's own domain — -1, None, "", NaN. It fails as soon as that value becomes legitimate data, and it fails silently. You will meet one in the exercises.

2 · Error types

A named type, discriminated by type rather than identity — so it can carry structured fields.

// go
type ValidationError struct { Field string; Rule string }

var ve *ValidationError
if errors.As(err, &ve) { report(ve.Field) }

Every Python except FileNotFoundError is this strategy; so is every Rust match on an error enum variant. It is strictly more expressive than a sentinel — the caller learns which field failed validation, not merely that validation failed.

The cost is the same cost, larger. The type must be exported, so now its shape is public: every field name, every method. And Cheney's sharpest version of the objection is about interfaces: “if your code implements an interface whose contract requires a specific error type, all implementors of that interface need to depend on the package that defines the error type.”

3 · Opaque errors

The caller knows the operation failed and nothing more. It adds context and returns it upward.

// go
cfg, err := loadConfig(path)
if err != nil {
    return fmt.Errorf("starting server: %w", err)   // context, no inspection
}

Cheney: this is “the most flexible error handling strategy as it requires the least coupling between your code and caller.” Nothing about the failure is public, so everything about it stays changeable. Rust's anyhow::Error and Box<dyn Error> are the opaque strategy given a type; a Python function that simply lets an exception propagate is using it too.

The obvious objection. If nothing is public, how does anyone ever retry a timeout? Cheney's answer is a rule worth memorising verbatim: assert errors for behaviour, not type. Publish a capability — a one‑method interface such as interface{ Temporary() bool } — and let callers ask “can this be retried?” without ever learning what the concrete error is. The question the caller actually has is behavioural; answer that question and nothing else.

What wrapping changed

Cheney wrote in 2016, before Go 1.13 put wrapping in the standard library. Wrapping partially rehabilitates the first two strategies, by separating what you compare from what you hold:

Wrapfmt.Errorf("…: %w", err) produces a new error that retains a link to its cause. The result is a cause chain.
errors.Is(err, target) — walks the chain looking for a sentinel. Use this and never ==.
errors.As(err, &target) — walks the chain looking for a type. Use this and never a bare type assertion.

The equivalents elsewhere: Python's raise … from e populating __cause__, and Rust's Error::source(). In all three, the chain-walking accessor is the correct tool and the direct comparison is a latent bug, because the direct comparison silently returns false the day someone upstream adds a layer of context.

Exercise 1 · Bug hunt

Click the line that carries the defect

Not every snippet has one. All four defects here are silent — they compile, they pass a happy-path test, and they return the wrong answer in production.

Exercise 2 · Recognition

Sort the API into strategies

Click an item, then click its strategy. These are drawn from real standard libraries across the three languages, so the categories have to survive translation.

Choosing, in practice

The literature does not give a rule, but it does give a way to reason: pick the weakest strategy that answers the question your caller actually has.

StrategyWhat goes publicReach for it when
Opaquenothingthe caller can only log, wrap, or give up. The default.
Behaviourone method namethe caller has a yes/no question — retryable? timeout? — that many concrete errors can answer.
Sentinelone value, foreverexactly one condition is a normal, expected outcome the caller branches on. io.EOF, ErrNoRows.
Typea whole struct shapethe caller needs data from the failure, not just its identity.

Note how this rhymes with lesson 01: Meyer asks you to write down whose obligation a condition is; Cheney asks you to write down what a caller is allowed to know about a failure. Both are the same discipline — making an implicit contract explicit — applied at different points of the interface.

Exercise 3 · Recall

From memory

Write before revealing.

Glossary — added this lesson

Sentinel error — a predeclared value compared for identity. Cheney 2016
In‑band sentinel — signalling failure with a value from the success domain (-1, None). Breaks when that value becomes legitimate.
Error type — a named, exported type discriminated by type, carrying structured fields. Cheney 2016
Opaque error — a failure the caller cannot inspect; the least‑coupling strategy. Cheney 2016
Behaviour assertion — discriminating by a capability the error exposes (Temporary() bool) rather than by its identity or type. Cheney 2016
Wrapping — producing a new error that retains a link to its cause, adding context on the way up.
Cause chain — the sequence of wrapped errors. Walked by errors.Is/As, Python's __cause__, Rust's source().

What this unlocks

You now have the vocabulary for a failure travelling up one call stack, one error at a time. Two things it does not yet cover, and both are on the fringe: what happens to the state you were halfway through mutating when the failure left the building (lesson 04), and what to do when several independent failures happen at once and there is no single cause chain to put them on — error aggregation.

← Lesson 02 Status map Lesson 04 →

Sources: Cheney, Don’t just check errors, handle them gracefully · Gandhi, An epic treatise on error models · RESOURCES.md