Issue #625: Error sets are closed at every catch, and a code parameter not kept fails per call (ADR-357, ADR-error-sets-1): build the rest

Open and answer this issue on GitHub.

ready · opened 2026-10-14 · updated 2026-10-29 · 5 comments

Decided

ADR-357 (owner, 2026-10-15) and ADR-error-sets-1 (owner, 2026-10-29, option A; commit bb42a123). Part I 7.1 says both.

The original question in this body (open or closed at a call through a function value) is answered; the text below replaces it.

Probe (develop 70f3237c, target/fast/nikaia, run 2026-10-29)

enum ReadError { Missing }
impl Error for ReadError { fn message(ref self) -> String { "missing" } }
enum ParseError { Bad }
impl Error for ParseError { fn message(ref self) -> String { "bad" } }

fn read() throws { throw ReadError::Missing }
fn parse() throws { throw ParseError::Bad }

fn retry(f: fn() throws) throws { f() }

fn main() {
    retry(read) catch {
        match error {
            ReadError::Missing => println("read failed")
        }
    }
    retry(parse) catch {
        match error {
            ParseError::Bad => println("parse failed")
        }
    }
}

Today: NK1151 This match doesn't handle ParseError::Bad at line 13 and ... ReadError::Missing at line 18. Wanted: builds, prints read failed then parse failed. The same two errors come with pub fn retry: inside one package, a pub function’s parameter also gathers every in-package caller’s argument (from(F::p) covers only callers outside). So the fix is not only for private functions.

To build

  1. Checker: per-call from(p) for a parameter that is not kept (ADR-error-sets-1 D1-D3). In params_as_places (crates/nikaia-std/src/tools/throws.nika, l. 183) a code parameter that is not in kept is no longer a place gathering its callers’ arguments; it is from(F::p), and every call site, in-package too, substitutes what it passed (as theirs, l. 379, does for other packages). A kept parameter stays a place. Files: tools/throws.nika, its tools/lowered/throws.rs (regenerated), crates/nikaia/src/contracts/throws.rs (caller of params_as_places, l. 73). Tests (Nikaia, tests/compiler/): the probe above, private and pub, .stdout read failed/parse failed; a catch around f() inside retry without else is NK1151; a parameter retry stores in a field still carries the union of both callers. In crates/nikaia/tests/it/function_value_errors.rs (Rust, changed only to keep passing): a_list_of_functions_fails_with_the_union and a_parameter_handed_on_chains_from keep passing; a_parameter_fails_with_what_its_callers_hand_it (l. 156) changes to the per-call answer. Lowering: the private call now goes the way a pub from(p) call goes (emit_catch_calls_given_code in tools/emit_core_flow.nika, ADR-error-sets-1 D4). If the probe runs once step 1’s checker is in, nothing is needed there; if it does not, that is a defect in emit_core_flow.nika: stop and report it, do not touch the file while lane E holds emit_*.
  2. Fatal types need no arm (ADR-357 D1, last clause). A match over error that names every non-fatal type is complete; a fatal type (process::Exit) passes on unnamed. Test: a set with process::Exit and Bad, the match naming only Bad, builds, and exit(3) still ends with 3. Files: the NK1151 completeness check over error (tools/check_access.nika l. ~212, tools/check_boxes.nika) and the fatal list (tools/check_may_fail.nika).
  3. D3: the grown set narrated. Where a committed ledger’s set grew under a complete match, NK1151 names the new type, the callee and the call that brings it in; a handler with else, or without a match, keeps NK2402. Tests: grow step’s set against a committed ledger, once with a complete match (refused, names type and call), once with else (warning). Files: tools/check_notes.nika, tools/check_state.nika, the ledger diff in crates/nikaia/src/contracts/.
  4. D4: NK2404. Warn the author of a pub function whose set grew against the committed ledger, at the statement that brings the new type in; committing the ledger acknowledges it (as ADR-288 D32). Test: a library function gains a throw, the build warns once, naming it. Files: as step 3; the code NK2404 exists in Part III C.3.

The commit that builds the last step says Fixes #625.

When it can start (lanes T, E, C running)


Generated by Claude Code

Comments

keywan-ghadami, 2026-10-15

Decision (owner, 2026-10-15): B, a catch that names every type is complete

Recorded in ADR-357 (D1-D5), Part I 7.1 and Part III C.3 (NK1151, NK2402, new NK2404). It changes ADR-023 D4, ADR-280 D18 and ADR-308 D11 (Changed by notes, What supersedes what). Version 0.0.966, commit af76c02.

  1. The set of error types arriving at a catch is closed. A match over error is complete when every non-fatal type in it is covered: each variant named, or the type taken by a bare name. A fatal type passes on unless named (ADR-345 D2). else stays allowed (D1).
  2. "?" or a from(p) not yet replaced in the set needs else (NK1151) (D2).
  3. When a callee’s set grows, a match that named every type without else is NK1151. The message names the new type, the callee and the call that brings it in, from the ledger diff. A handler with else, or one that does not match, keeps the NK2402 warning (D3).
  4. NK2404 (warning): the author of a pub function whose set grew since the committed ledger is told, at the statement that brings the new type in, that callers naming every type will stop building. Committing the ledger acknowledges it, as ADR-288 D32 does (D4).
  5. A complete match lowers with no catch-all arm (D5).

What it costs, as weighed with the owner: a new error type in a pub function stops dependents’ complete handlers. That is the same cost a new variant already had. A caller who wants robustness writes else. There is no run-time cost.

To build

  1. Checker. Use one completeness rule for every catch, with direct calls, methods and calls through code alike. Exclude fatal types. "?" or an open from(p) needs else. Tests:
    • a direct call throwing Bad and Worse, with both named and no else, compiles and runs;
    • the same with Worse missing is NK1151;
    • a set with process::Exit is complete without naming it, and exit(3) still ends the program with 3;
    • a catch over an undescribed call needs else;
    • function_value_errors::a_list_of_functions_fails_with_the_union keeps passing.
  2. Lowering. A complete match writes no _ arm. Test: the Rust of the direct-call example has no _ =>.
  3. Growth. NK1151 with the ledger narration where a committed set grew under a complete match, and NK2402 only for handlers with else or without a match. Tests: grow step’s set against a committed ledger, once with a complete match (refused, message names the new type and the call) and once with else (warning).
  4. NK2404. Warn when a pub function’s set grows against the committed ledger, at the statement that brings the type in. Test: a library function gains a throw, and the build warns once, naming it.

The commit that builds the last step says Fixes #625.


Generated by Claude Code

keywan-ghadami, 2026-10-28

State after 642b0d9 (0.0.1257), built while working on #673 (brief docs/briefs/b673b-catch-by-struct-type.md):

Built from ADR-357:

Still missing:

Related and filed separately: #918 (an else written beside arms that name every variant is lowered as _, and rustc’s Unreachable pattern reaches the user), #912 (the short form catch { Type => … }, ADR-catch-1).


Generated by Claude Code

keywan-ghadami, 2026-10-29

Triage (#982), class D: relabelled decision. The body says it needs one: whether the error set at a call through a function value is closed (ADR-351) or open (Part I 7.1).


Generated by Claude Code

keywan-ghadami, 2026-10-29

Decision paper (architect, 2026-10-29): is the error set at a call through a function value closed or open?

Where the record stands now. The owner took this question on 2026-10-15 (comment above, ADR-357): closed everywhere. Part I 7.1 was rewritten to match (“A match over error is complete when it names every type that can arrive”), so ADR-351 and Part I no longer contradict each other. The triage relabel to decision (#982) read the issue body, not the decision. What is still open is building: ADR-357 D3 (growth narration), D4 (NK2404) and the clause that a fatal type need not be named. The owner asked to look at it again now that ADR-lambdas-1 says once-ness is inferred and never written. This paper does that, and finds one real gap the first round missed (section 4a).

1. The question in plain words

throws names no types. The compiler works out what each function can fail with. When code is called through a value (a field, a list element, a parameter), does a catch there know the full list of what can arrive, so that naming every type is enough? Or must it always say else, because more could come?

enum Bad { Oops }    impl Error for Bad   { fn message(ref self) -> String { "oops" } }
enum Worse { Boom }  impl Error for Worse { fn message(ref self) -> String { "boom" } }
fn fail() throws  { throw Bad::Oops }
fn worse() throws { throw Worse::Boom }

fn main() {
    let all: Vec[fn() throws] = [fail, worse]
    for run in all {
        run() catch {
            Bad::Oops   => print("oops")
            Worse::Boom => print("boom")
            // closed: complete, builds. open: NK1151, needs `else`.
        }
    }
}

2. Closed and open, weighed

Measure Closed (ADR-357, today) Open (old Part I 7.1)
Simple as a scripting language Nothing is written: no else you do not need, no type list. The handler says what can happen. Two rules: variants of one type are exhaustive, but the set of types is not. Every handler over two types carries an else that is often dead code.
Error messages NK1151 can name the exact missing type and the call that brings it in. A set that grows breaks the build at the catch, with the ledger’s narration (D3). An else hides growth. NK2402 is only a warning, which people learn to ignore.
What a library author can promise “This is all I fail with”. That also means adding a type breaks callers’ complete handlers (like a new enum variant). NK2404 warns the author. Nothing. The author is free to grow the set. Callers cannot prove they handled everything.
Rust lowering One generated enum per set (ADR-280 D15), matched with no _ arm (D5). Generic E for from(p) (ADR-351 D8). Box<dyn Error> only for "?". The same enum and generics (the compiler still knows the set), plus a _ arm. Open buys nothing in the lowering.
Compile cost The set is computed anyway for the lowering. A completeness check over a small finite set costs almost nothing. The same inference. Only the check is skipped.
Separate compilation A dependency’s ledger is part of its API. An update that grows a set stops complete handlers in dependents. A dependency can grow its set without stopping anyone.

So the trade is real but narrow. Closed costs one thing: growth across a package boundary is a breaking change. Open costs a dead else in every program and the proof that a handler handles everything.

3. How others do it

From memory, not checked against current manuals.

Language Their way What it costs them
Java Checked exceptions, closed, written by hand The written lists do not compose with lambdas: java.util.function declares no throws, so a stream lambda that throws must wrap its exception in a RuntimeException. A generic throws E carries only one type. That is why the industry moved to unchecked exceptions. The pain comes from writing the lists, not from their being closed.
Swift throws (untyped, open: any Error), rethrows (a function throws only if its closure argument does), and typed throws throws(E) (SE-0413), which is generic and exhaustive over E rethrows covers yes/no only, not which errors. Typed throws composes through generics, but a function with two failing closures needs a written union type. SE-0413 itself advises untyped throws for most public APIs, so that libraries can evolve.
Kotlin, C# Unchecked. Nothing in the type. Simple. No handler is ever complete, and what a function throws lives in documentation only.
Rust Result<T, E>, E generic, ? converts with From. Closed per type. #[non_exhaustive] opts out. Composition is manual: an error enum per layer, From impls (or thiserror), or Box<dyn Error>/anyhow to give up precision. Two closures with different E in one Vec need a common type written by hand.
Zig Error sets inferred for a function (!T), merged with \|\|, closed, and switch over them is exhaustive. anyerror is the open escape. Inference stops at function pointers and at recursion: a function pointer type must name its set or use anyerror, so precision is lost exactly where this issue sits.
Koka, OCaml 5 Koka: effect rows, inferred and polymorphic (<exn\|e>). A higher-order function’s row is its own plus its argument’s, per call. OCaml 5: effects are untyped; polymorphic variants give inferred, closed or open rows. Koka is precise and needs no annotation, but its messages show row variables (e) that users have to learn. OCaml’s open/closed row types ([> ], [< ]) are famous for hard-to-read errors.
Scala 3 CanThrow[E] capabilities (experimental): throws E is sugar for a context parameter, and a lambda captures the capability It composes with lambdas through capture checking, but it is still experimental. Inference across stored closures needs capture types, a large addition.
Go error is an interface value, open, with errors.Is/As Simple. No exhaustiveness at all, and every caller writes if err != nil.

The lesson for Nikaia: precision and composition only coexist where the set is inferred and polymorphic (Koka). Everyone who writes the set loses composition with lambdas (Java, Swift without generics, Zig at function pointers). Nikaia already infers the set and already has the polymorphic member from(p), without a written row variable. So the remaining question is not closed versus open. It is how precise the inference is at a code parameter.

4. What Nikaia’s inference makes possible

4a. Per-call-site rethrows, inside the package too. This is the gap. Today (crates/nikaia-std/src/tools/throws.nika, params_as_places), a code parameter of a pub function, or of one used as a value, is published as from(p) and replaced per call site by what that caller passed (ADR-351 D4). That is rethrows with the which included. A private function’s code parameter is instead a place holding the union of what every caller hands it (ADR-351 D3, test a_parameter_fails_with_what_its_callers_hand_it). So with a private fn retry(f: fn() throws) called as retry(read) and retry(parse), the catch around retry(read) must also name ParseError, which cannot arrive there. (I read this from the code and have not run a probe; the first step of To build below confirms it. A catch inside retry’s body rightly sees the union, because that body runs for every caller.) Under an open set this imprecision was hidden behind else. Under a closed set it becomes an arm the user has to write for nothing, and a message that names a type that cannot reach this call. The fix uses machinery the compiler already has: treat every code parameter that the body only calls or hands on (it is not kept: ADR-094 keeps, the same walk ADR-lambdas-1 D3 runs for once-ness) as from(p), and replace it at each call site. A code parameter that is kept stays a place with the storage’s union (ADR-351 D8), because stored code really can come from any caller.

4b. Closed inside the package, open at its boundary. A catch over a call whose set comes, even in part, from another package’s ledger would need else, and inside one package it would be complete. This removes the cross-package breaking change. The costs: the rule depends on where code lives, so moving a function between packages changes whether a handler builds. Every handler over std (which is another package, e.g. fs plus regex) needs else again, which is the common case in scripts. And ADR-357 D4 (NK2404) already gives the author the warning. Checker effort: low (one bit per set member, its origin). It weakens what a library can promise and brings back the two-rule language.

4c. What catch and exhaustive match error need. Closed needs a finite, named set at the catch. That means no "?" and no unreplaced from(p) (ADR-357 D2), which 4a makes rarer, and precision at code parameters (4a), or users write arms for impossible types. Open needs nothing but else. ADR-catch-1’s short form works with both.

Can the current inference carry it? Yes, for 4a. It is a least fixpoint over a finite set of names (throws_grow), and from(...) members are already carried through it. Recursion through a code parameter (a function that hands its own f to itself) ends as now, because the sets only grow. Not carried, and not proposed: per-call-site precision for stored code. That would need flow analysis, which ADR-351 D8 refused on purpose.

5. Options and recommendation

(Reverting to open everywhere is not offered as an option. It undoes ADR-357, and the lowering, the messages and fatal-error tracking all already rely on the closed set.)

Recommendation: A. It confirms the owner’s 2026-10-15 decision and fixes the one place where closed sets cost the user something the old open rule hid. The cross-package growth cost stays as ADR-357 weighed it: the same cost a new enum variant has, else for whoever wants robustness, and NK2404 for the author.

Draft ADR text (for A)

ADR-errsets-1: A Code Parameter That Is Not Kept Throws What Each Caller Hands It

Status: Proposed. Answers: whether the set a call to a function with a code parameter fails with is the union over every caller or what this caller passed (#625). Related: ADR-351 D3, D4, D8; ADR-357; ADR-094 D2 (keeps); ADR-lambdas-1 D3.

D1 — A code parameter that the body calls or hands on, and does not keep, is from(p) in the function’s set, for every function, public or not. At each call, from(p) is replaced by the set of what that call passes. A catch around the call sees that set and nothing that other callers pass.

D2 — A kept code parameter stays a place. Where the body keeps it (keeps, ADR-094), the set belongs to the storage it enters, the union of everything put there (ADR-351 D8).

D3 — Nothing is written. As for throws (ADR-023) and once-ness (ADR-lambdas-1 D3), the fact is read off the body and written to the ledger. There is no row variable and no rethrows.

D4 — Lowering. A function with a from(p) member is generic over that parameter’s error type (ADR-351 D8), and each call fills it with the generated sum of what it passed.

Reasons. Under ADR-357 a catch without else must name every type that can arrive. With a union over callers it would be asked for types that cannot arrive at this call. Koka’s rows and Swift’s rethrows show that the per-call answer is the one that composes with lambdas. Nikaia gets it without the written variable they need. Rejected: union over callers (above); a written rethrows (a word the body already answers, ADR-258); per-call precision for stored code (needs flow analysis, refused in ADR-351 D8).

To build (if A)

  1. Checker. params_as_places publishes from(F::p) for every non-kept code parameter, and private call sites substitute it. Tests (Nikaia, tests/compiler/): a private retry(f) called with two lambdas that throw A and B; the catch around each call names only its own type and builds; a kept parameter still carries the union.
  2. Lowering. The private function is generic over E. Test: both calls run and print their arm.
  3. Then the remaining ADR-357 steps above (D3 narration, D4 NK2404, the fatal-type clause).

Owner decides: A, B or C.


Generated by Claude Code

keywan-ghadami, 2026-10-29

Owner’s decision (2026-10-29): option A is accepted.


Generated by Claude Code

Back to Open Issues