ADR-308: What the Compiler Says About a 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 Once

Status: 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)

1. Question

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:

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.

2. How others do it

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.

3. Options, weighed

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

4. Decision

D1 — A handler that does not mention 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).

D2 — The question is asked of the handler’s block by the walk that already exists.

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.

D3 — The walk over-approximates, and that is the safe direction.

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.

D4 — The bound name is spelled in one place.

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.

D5 — Not an 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.

D6 — A 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.

D7 — Refused rather than dropped.

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.

D8 — The walk keeps two answers, and only the first may be acted on.

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.

D9 — Running a grammar is an ordinary call, and its contract says it can fail.

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.

D10 — A nested 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.

D11 — When a callee’s 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.

D12 — Under --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.

D13 — A handler that matches and one that does not are told alike.

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.

D14 — The syntax of 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.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.