throws Declares, and What an Error CarriesStatus: 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)
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.
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:
throws as a boolean. The file whose entire
purpose is to say what a caller must know about a body it cannot see already
decided that “can it fail” belongs there. Recording with what is the same
entry, widened.throws implicitly because a resource it holds can fail at close. With a type
list the author would have to name a type they never mentioned — and that type
would change when they swapped a buffered file for an unbuffered one.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.
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.
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.
| 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.
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.
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:
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.
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.
?.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.
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:
=> — a failing action fails the match, exactly as a failing
pattern does; the parse backtracks.=> — the error leaves the parser as the Nikaia error it is, with
its type, payload and origin mark, and reaches the catch beside the dsl.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.
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).
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.
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.
wasm32 supports unwinding only partially, and
ADR-297 rests on throws tearing down predictably. Recorded
because it is the correct answer to “the happy path should pay nothing”, and
someone will propose it again.Cleanup obligation between it and the
handler. What “resume” means there has no cheap answer.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.