ADR-023: Errors — What throws Declares, and What an Error Carries

Status: Accepted Closes: the half of Part I Chapter 7 that was never written Related: ADR-005 D3 (derived truth lives in the ledger), ADR-297 D4 (a function acquires throws it never wrote), ADR-020 (the ledger entry D1 widens), ADR-025 (the same question for loops), ADR-280 (answers §4.2)

1. Context

Chapter 7 specified throws and catch and then stopped. Everything a program needs in order to actually fail was missing: nothing raised an error (throw appeared once in the specification and was defined nowhere, and was not in the parser); nothing declared an error type; catch bound the name error on the authority of an implementation ADR crediting a specification that had not been updated; every example wrote untyped fn main() throws, a form the specification did not offer; and a postfix ? was used in three places in two different meanings.

This could not wait, because three shipped mechanisms already rested on it: ADR-297 D4 gives a function a throws obligation from implicit cleanup, ADR-020 records throws in the ledger and ships it with std, and Appendix C reserves a code range for it.

Nothing below is settled by what the bootstrap compiler can lower.

2. Decisions

D1 — throws carries no type list; the error set is inferred into the ledger.

In source, throws is bare. What a function can fail with is inferred whole-program and written to nikaia.contracts.

The argument is ADR-005 D3’s, unchanged: the error set is derived truth — it follows from the body — and derived truth in source code is an invitation to go stale. Two reasons specific to errors:

The cost, stated rather than hidden. Without a declared set, a catch is checked against the inferred set, so adding a throw deep in a callee can make a distant catch non-exhaustive. Same failure mode as ADR-005 D3 faced for borrows, same answer: the ledger diff narrates what changed, which contract moved, and which caller broke.

Checked exceptions are remembered as Java’s mistake, and the reflexive objection does not apply. They failed there because every type was written by hand, with no inference and no subsumption, so people wrote throws Exception or swallowed the error. The idea is older than that failure — CLU introduced it on the argument that the exception is part of the interface — and both halves survive: the interface should carry it, and writing it by hand kills it. Inference plus a ledger is the combination Java did not have.

D2 — throw exists.

An error is created and raised with throw. Without it only std could fail and user code could only propagate — which ADR-297 D4 already contradicts by handing functions failure obligations they did not ask for.

throw inside a catch re-raises; no separate construct.

D3 — An error type is an enum, and it carries payload.

NotFound without the path it did not find is a message that costs a round trip to the user to become useful. The enum is what the language already has for “one of a fixed set of things”, and match over it is checked for completeness.

D4 — Exhaustiveness and extensibility live on different axes.

axis closed or open
the variants within one error type closed — match is exhaustive, and adding one is a breaking change, correctly
the set of error types arriving at a catch open — inferred, and it grows when a callee gains a failure

Changed by ADR-357 D1-D3: the set of types arriving at a catch is closed as the variants are; a match naming every type is complete, and a grown set refuses it (NK1151).

A catch matches with the patterns the language already defines: a path with :: names a variant, a type’s name - bare or by its path - covers that type, and a bare name that is not a type binds. What the compiler supplies is the type of error — the sum over the inferred set at that point.

The residue, named. Behind a dyn call or an FFI boundary the compiler cannot see the set, so exhaustiveness degrades to “and anything else”. The same residue ADR-005 D3 accepts for trait methods and ADR-010 accepts for provenance — which means “exhaustive” is never a claim about the whole program.

D5 — No classification codes.

error.code == io::NOT_FOUND would be the enum again in a weaker form: unchecked by the compiler, unable to carry the payload, and a permanent API commitment the moment anyone matches on it. The enum is the classifier.

What codes do elsewhere — let the programmer find the place — is D6’s job, done better: the answer is the place rather than a token to look up.

D6 — An error knows where it was raised, with a short form a person can read out.

what the user sees:   Something went wrong.  (NK-2C7)
what the developer:   nikaia explain NK-2C7
                      net::fetch_config · site 2 · IoError::NotFound
                        at release:    net.nika:212
                        in your tree:  net.nika:238

Four properties, each load-bearing:

  1. The key is source-derived, not positional — module path, function, which raise site within it, and the variant. ADR-005 D8’s ban-list rule applied to a second kind of key. A line number would move under an edit above it; this does not.
  2. A developer two features ahead need not check out the release. The table ships the line as it was; the tool resolves the key against the tree in front of you, and says so where the site no longer exists.
  3. No log and no service is required. The table is a build artifact shipped beside the binary, like a debug symbol file, resolved offline. A team with no log aggregation gets from a screenshot to a source line.
  4. Which build is context, not identity. The key is stable across builds, so occurrences aggregate for free and the application reports its own version next to the mark.

The derivation is a language property, because it needs the source map ADR-300 already builds. Whether a mark is shown is the application’s decision.

D7 — The default rendering is short and safe; detail is asked for.

Changed by ADR-280 D4: detail is asked for with the method call error.full(), not a format spec.

{error} yields the message and, where the application asks for it, the origin mark. It never yields a stack trace. error.full() yields everything.

The rule is that the form you get without thinking is the form that is safe in front of a stranger. ADR-289 established the constraint — an error message is written for the operator, which is why a failing handler answers with a generic body and logs the rest — and this makes it true of the default rendering rather than of one call site.

D8 — There is no postfix ?.

throws propagates on its own, so an operator that marks propagation marks nothing: all.fetch(db) and all.fetch(db)? would mean the same. Rust needs ? because propagation there is explicit; here there is no match being abbreviated.

The decisive argument is that implicit async already answered this, in the other direction. A Nikaia program has four invisible control-flow events:

event marked? decided in
a call may pause no Part I 8.1
a block’s end may pause and fail no ADR-297 D1, D4
a loop’s step may fail no ADR-025 D2
a call may fail and return early no this decision

Marking one of four is worse than marking none: a ? on one line implies the lines without one are safe, and they are not — every one may pause, and the closing brace may fail. Half-visibility is misleading visibility. Marking all four would be async/await with extra steps, which is the bet the language exists to refuse.

Supporting: the character already carries four jobs (String?, ?., ??, and a postfix ? for an optional element inside a grammar), and the fifth would collide with the fourth in the same file, because action blocks hold ordinary expressions.

The honest cost. The source no longer distinguishes a call that can fail from one that cannot, and a reviewer reading a diff in a browser has lost that. The ledger records throws per function and tooling can show all four events — but not in a pull request. That is the price, and it is real.

D9 — A grammar action may fail, and the commit point decides what that means.

A rule has two halves that fail differently. The pattern says what is well-formed, and its failure is defined: backtrack, or past a commit point, stop. The action says what is meaningful, and its failure had no definition. Every non-toy grammar lives in that gap — digit+ matches 99999999999999999999 and it is not an i32.

The commit point already draws this line, and Part II 10.1 already says so: “once the parser passes a commit point … a later failure is an error” — a later failure, not a later pattern failure. So:

Why this rather than a new spelling: => exists to separate “try something else” from “this is wrong”, so applying it to one more kind of failure adds nothing to learn. It settles the side-effect problem for free, because backtracking over a failed action is dangerous precisely when earlier actions already ran, and commit points are how a grammar author makes that safe — which makes an accumulating rule’s requirement checkable: a par_fold’s rule must be committed. And the backend already distinguishes a backtracking error from a cut one, so nothing is invented and one thing is routed.

This is not a marker. => does not say that something can fail; it says where backtracking stops, which is a grammar concept that predates this and means the same as before.

Where the pattern can say it, the pattern says it. ADR-296 D13 already requires a width bound so that a rule rejects a three-digit temperature rather than parsing one and checking afterwards. An action validates only what a PEG cannot express.

Three things this settles on the way: before the commit point the error’s message becomes the expectation text and the payload is dropped, since a speculative alternative’s error is discarded anyway. A failing action inside a piece has two positions — in the input and in the program — and both are reported, because they answer different questions. And recover(…) does not catch it: recovery resynchronises after a syntax failure, and a semantic error past a commit point is exactly what must not be skipped.

Following ADR-025: the fact that a rule can fail is recorded in the ledger, because a caller of a published grammar cannot see the body; and Stage 0 decides it by asking the type checker whether the action can fail, never by matching a name.

D10 — The error representation is pointer-sized, and the success path pays no memory round trip.

A cost guarantee in the sense ADR-296 D12 uses: normative, not aspirational. The threshold is architectural — on x86-64 a returned value of up to two words comes back in registers, and beyond that every call pays a memory round trip — so the question is not how wide an error is but whether the returned pair stays under it.

An error value occupies one word whether or not its variants carry data. The compiler picks the encoding per type: all-empty variants are the tag itself and never allocate; a payload that fits is packed in; anything larger is boxed, and that is the one place an allocation happens. Rust’s own io::Error is the existence proof.

What remains, and is not payload’s fault. A throws call must test and branch on return: one predictable branch per call. That cost belongs to checked errors, not to payload — Zig pays it too. The only design that removes it is unwinding (§6).

3. Open

3.1 — Where does the success/failure decision live?

Two encodings, both satisfying D10.

A — in the value. A tag, ideally swallowed by a niche, so the pair stays one word. This is what a Rust backend gives us.

B — beside the value. The status travels out of band. The instinct is usually stated as “use the carry flag”, and in that literal form no compiler back end can reach it: LLVM does not model CPU flags across calls. The realistic form exists and is in production — LLVM’s swifterror passes an error in a designated register, leaving the return value alone, and Swift has shipped on it for years.

Interoperability argues for A, and not as a concession: a boundary is where calling conventions get translated anyway, so a shim is ordinary — but a shim has to exist at every crossing, and a representation that is Rust’s needs none. As long as dropping into an existing Rust stack is part of the value proposition, sharing the convention is a feature.

Open: commit to A permanently, or keep B for a backend that controls its own calling convention.

3.2 — What an automatic stack trace costs

Answered by ADR-280: about 28 300 instructions per error, so a trace is asked for rather than attached, and an error carries its site rather than a trace.

4. Considered and rejected

5. Consequences

Part I Chapter 7 specifies how an error type is declared, how one is raised, what catch binds, and D7’s rendering rule.

The ledger’s throws entry is the inferred set, not a boolean — part of std.contracts, of what a published package distributes and of what --locked compares.

A new build artifact: D6’s origin-mark table, a projection of the source map rather than a second source of truth, and therefore a cache-key dimension under ADR-021 D13.

A gap deliberately left open. ADR-289’s generic 500 leaves the user with nothing to quote. D6 says where in the code; it does not say which run, and for a service with more than one process that is the question an operator asks. That identifier belongs to the unit of work, not to the error — an error that mints its own identifier beside an incoming trace identifier produces two numbers to join instead of one. It waits on the runtime binding, and folding it in would make everything above wait with it.

This site is open source. Improve this page.