catch — an Unread Error Is Bound as _error, a catch Over What Cannot Fail Is Refused, and an Error That Newly Reaches One Is Named OnceStatus: Accepted
Date: October 3, 2026
History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached
Answers: #221
(a catch that ignores the error warns about a name the emitter wrote),
#230 (a catch on an
expression that cannot fail does not compile), and the language review’s
question a catch { … } absorbs a callee’s new failure in silence - does the
syntax have to change?
Related:
ADR-023 D1 (throws names no types; the set is inferred into the ledger) and D8 (a failing call propagates, unmarked) ·
ADR-297 D3 (NK2605: a call that can fail where nothing handles it - the same question asked from the other end) ·
ADR-280 D10 (a handler is handed the error, the envelope opened once) ·
ADR-292 D20 (rustc says nothing about the file it is handed) and its rule that a return in a handler is the function’s ·
ADR-296 D17 (the lowering writes a name for a name), D24 (a grammar is entered by a call) and D38 (every entry throws ParseError) ·
ADR-017 (a template block, whose output is the emitter’s business) ·
ADR-005 D3 and Part III 13.5 (the ledger, and the three rules for a contract that changes) ·
ADR-100 D4 (--locked compares the ledger byte for byte) ·
ADR-300 (a diagnostic names the file the user wrote)
What does the compiler say about a catch - and when does it say nothing?
value catch { … } is the one form of handling (Part I 7.1). It handles
everything that reaches it, names the failure error inside the block, and
lowers to a match over a Result. Three shapes put that form in front of the
compiler with no rule to answer them:
error - read_port() catch { 8080 }, the
shape Part I 7.1 teaches first. The lowering binds error anyway, and rustc
answers unused variable: error about a generated file and a name the
author never wrote: Part III C.1’s class, one
severity down.catch over an expression that cannot fail -
text.len() as i64 catch { 1000 }. The match is over something that is not
a Result, and rustc refuses the generated file with E0308, naming an
Ok arm nobody wrote.throws names no
types (ADR-023 D1), so the set arriving at a handler is open
and grows whenever a callee gains a failure. The handler is still a correct
program and still handles the new error, as it handles everything. Nobody is
told.The first two are messages in the backend’s words, which the language does not
allow. The third is silence where a reviewer needed one sentence. Each has to
be decided here because the obvious defaults are wrong: silencing rustc
leaves the emitted code saying something it does not mean, dropping a dead
handler deletes checked code, and refusing a handler for a grown set refuses a
correct program.
| Language / tool | Its answer | What it costs there | Source |
|---|---|---|---|
| Java | checked exceptions: the signature lists the types, so a new failure is a compile error at every caller that does not handle it | a type list in every signature, the mistake checked exceptions are remembered for | from memory, not checked |
| Rust | the error is a value; a binding nobody reads is _name by convention, or the warning is silenced with #[allow(unused_variables)] |
the convention is the author’s to remember | rustc’s own warning, read on the emitted code |
Nikaia’s NK2605 |
a call that can fail where nothing handles it is refused, and only where the callee’s contract says it can fail; a callee no ledger describes says nothing | a call nobody described is never refused | ADR-297 D3, Part I 7.1 |
What does not transfer: Java’s answer needs the type list Nikaia declines at
the signature (ADR-023 D1), and Rust’s convention is the author’s
in Rust, while here the binding is the compiler’s own - so the compiler is the
one that has to follow it. What does transfer is NK2605’s standard: the
ledger is the fact, and only what it says may be acted on.
| Shape | Option | Example | Outcome |
|---|---|---|---|
unread error |
bind _error where the handler does not mention error |
Err(_error) => { 1000 } |
chosen (D1) |
#[allow(unused_variables)] on the arm |
the same arm, silenced | rejected: the warning is rustc reading the emitted code correctly; silencing it leaves the code saying something it does not mean, and silences the day it is about something real (D5) |
|
| a precise is this name read analysis | a second walk | rejected: it would have to be right, where an over-approximating walk only has to lean (D3), and a second notion of read is a second thing that can disagree (D2) | |
| over what cannot fail | refuse it, NK1134 |
text.len() as i64 catch { 1000 } is refused |
chosen (D6, D7) |
emit the expression without the match, silently |
the handler vanishes | rejected: deletes checked code from the program the author wrote, a return in the handler included (D7) |
|
| refuse wherever failing is not proved | a catch over an undescribed call is refused |
rejected: a call nobody wrote down says nothing; reading that as cannot fail refuses correct programs (D8) | |
| a grown set | name each handler once, in the build output; --locked fails until the ledger is committed |
NK2402 |
chosen (D11, D12) |
| refuse the program | the build stops | rejected: the handler is correct and handles the new error | |
a pattern or type list on catch, or a marker at the handler |
catch io::DiskFull { … } |
rejected: not needed for the guarantee, and a type list is the checked-exception cost (D14) | |
| say nothing | silence | rejected: the question is this error right where it landed? is exactly what a reviewer must see |
The weights of ADR-258 agree throughout: the compiler does the work (D2 there) - it writes the right binding, reads the ledger, diffs the set
error binds _error.The name is still there - Part I 7.1’s promise holds, and a handler that reads
the failure gets it as error. Only an arm whose handler does not read it binds
_error, so the emitted code gives rustc nothing to warn about. A handler that never reads the error opens no envelope
(ADR-280 D10).
Whether a block mentions a name is answered by the walk the ordering analysis already runs over every block’s names. There is no second walk and no second notion of is this name read, because a second notion is a second thing that can disagree.
The walk counts a word inside an interpolated string’s raw text, so
f"{error}" is a mention, and it may count the word where it is not read. The
two ways of being wrong are not symmetric:
| the walk says | the truth | written | what happens |
|---|---|---|---|
| mentioned | mentioned | error |
correct |
| mentioned | not mentioned | error |
rustc’s unused-variable warning, nothing worse |
| not mentioned | not mentioned | _error |
correct |
| not mentioned | mentioned | _error |
a program that does not compile |
Only the last row is a new failure, and an over-approximating walk cannot reach it. A precise analysis would have to be right; this one only has to lean.
The spelling the emitter writes and the spelling the walk is asked about are one constant. Two spellings would be a binding that goes unread while the walk looks for the other - the same defect, arrived at from the other side.
allow.The warning is rustc reading the emitted code correctly, and the emitted code
is what changes (ADR-292 D20). An allow would leave the code
saying something it does not mean, and would silence the day the warning is
about something real.
catch whose guarded expression is known not to fail is NK1134.Known, not merely unproven: D8 is the whole of what that means. The help is the
whole repair: delete the catch and its handler: the expression is the
value. It is reported at the statement carrying the catch.
Emitting the expression without the match would take the program the author
wrote and quietly delete a block from it. That block is checked code: a
return inside a handler is the function’s return (ADR-292),
so dropping it changes what the program does. And a handler that can never run
is a belief about the program - the author thinks something here can fail -
which is worth a sentence whether or not keeping it costs anything.
While a guarded expression is walked, the checker records something here can fail and something here could not be looked up separately:
| the walk found | what it means | NK1134 |
|---|---|---|
a contract carrying a throws |
the catch has work |
quiet |
| a call or method no contract describes | no answer | quiet |
a nested catch |
no answer (D10) | quiet |
a template block or an asm block |
no answer (D9) | quiet |
| none of these, and everything resolved | nothing can fail | refused |
A call nobody has written down says nothing about whether it throws
(Part III C.4: the compiler says nothing where a
side is not written down, which is not the same as approving), and letting that
read as it cannot would refuse programs that are right. The refusal fires on
known not to fail, never on not known to fail - NK2605’s standard
(ADR-297 D3) read from the other end.
A grammar is entered by a call to an entry rule (ADR-296 D24),
and every such entry carries throws = ["ParseError"] in the ledger
(ADR-296 D38), because a rule past a commit point can fail
(ADR-023 D9). So a catch beside Json::value(input) is
answered from the callee’s contract like every other call, and has work. Nothing
about grammars is special-cased in this walk. A template block
(ADR-017) and an asm block are not calls: what they become is
decided by the emitter after the checker has run, so both answer no answer.
catch makes the outer one quiet, not refused.(text.parse() catch { 1 }) catch { 2 } really has nothing left for the second
handler: the first consumed it. Saying so needs to know what a handler’s own
failure does, which is not settled here. So the outer guard is told no
answer, and the shape stays a program. This is a deliberate hole, in the
direction that cannot refuse a program that is right.
throws set gains a member, every catch over that callee is named once in the build output.The ledger records throws as a set (ADR-023 D1). A build that
finds a function’s set grown against the committed ledger names each handler
the new error now reaches - main.nika:12 receives io::IoError from
read_port now, which it did not when it was written; it handles it as it
handles everything - and carries on. This is NK2402, a warning, given at
the call inside the handler rather than at the catch, because that is
what the sentence is about: this call brings something new in here. It is
Part III 13.5’s second rule - changed, all callers
still valid: updated, and noted in the build output - applied to a handler. A
program is never refused for it.
Changed by ADR-357 D3: a handler whose match names every type and has no else is refused when the set grows (NK1151, narrated from the ledger); the warning stays for a handler with else and one that does not match.
Three cases are nothing gained: a build with no committed ledger (a first build is not a change), a committed ledger that does not parse (not an answer), and a function the committed ledger does not name (a new function’s whole set is not a set that grew). Because the narration is the ledger’s, a library’s ledger read by a consumer (ADR-100) carries the grown set across the package boundary, and the consumer’s build names its own handlers.
--locked the grown set is a failure until the ledger is regenerated and committed.--locked compares the ledger byte for byte (ADR-100 D4), and a
throws set that gained a member is a changed line. The commit of that diff is
the acknowledgement: it carries the error’s name and the handlers it reaches, a
reviewer sees both, and after it the grown set is the baseline, so the note is
given once. No marker in the source, no annotation, nothing to type at the
handler.
A catch that reads error, one that ignores it, and one that dispatches with
match error { … } all receive the same note, because the question is the
same for all three: is this new error right where it landed? What the author
does about it - a new arm, or leaving it where it is - is theirs, and both
answers are legitimate.
catch is unchanged.value catch { … } is the one form, and it handles everything that reaches
it. A pattern in front of the handler is not needed for D11-D13 and is not
added for them.
catch { 1000 } lowers with an
unread _error; the emitted arm is one character wider and means the same.
No diagnostic is added - the fix is the absence of one. The cost is one walk
of the handler’s block per catch at lowering time, the walk the ordering
analysis already runs.catch the program does not need is a refusal in Nikaia’s words
(NK1134, Part III C.3), never E0308 about a generated file. The cost is
two answers kept on the checker’s existing paths.throws. Since D9 reads it from the
contract, an entry without one would make every catch beside a parse
NK1134.--locked failure in CI that a committed ledger clears.
Part I 7.1 states the rule; Part III C.3 lists NK1134 and NK2402.NK2605 and NK1134 stay two diagnostics: they ask one question and answer
opposite halves of it, and the repairs have nothing in common - one adds a
throws, the other deletes a block.error is the right name. Part I 7.1 chose it and this keeps it;
D4 makes changing it a one-line change.unused variable warnings about names the user did write - a let,
a for binding. Those are actionable; what is wrong is the file they point
at, which is ADR-300’s question.NK1134’s caret lands. On the statement, because a catch has no
span of its own; an expression-level span exists for one construct
(ADR-081 D1), and this would be the second.catch is decided.Result shape is decided here.catch takes a pattern. Not needed here, and not asked.