Rust Made Errors Values. It Didn't Make You Handle Them.
Koru's continuation branches make partial handling a type error — the call site is the match.
Rust’s error-handling story is usually told in two slogans. Errors are
values — a Result<T, E> is data you return, not a stack you unwind. And
the audit is grep for unwrap — find the places that assume success and
panic when they don’t get it.
Both slogans are about the same narrow thing: unused returns and loud
panics. #[must_use] makes a dropped Result a warning. unwrap makes a
wrong assumption a crash. That is genuinely good — it is better than
exceptions, better than error codes, better than null.
But walk the grep’s blind spots:
if let Ok(contents) = read_file("config.toml") {
process(contents);
}
// Err evaporated. No unwrap anywhere. Clean compile, clean grep. let contents = read_file("config.toml").ok()?;
// E — the entire reason the type is two variants — discarded
// before anyone looked at it. match read_file("config.toml") {
Ok(c) => process(c),
Err(_) => {} // the arm is present. The handling is not.
} let _ = read_file("config.toml"); // satisfies #[must_use] Every one of these passes the audit. if let Ok declines the Err continuation without naming it. .ok() deletes the error type. Err(_) matches everything inside the variant and does nothing — and let _ = answers #[must_use] while reading nothing. The slogans police loud failure. They say nothing about quiet absence.
That is the hole this post is about. Not that Rust’s Result is bad — it is
a good two-continuation type. The hole is that the language only checks one
of the continuations, and the audit only finds the loud half of that.
One continuation is privileged
Look at what the syntax does for you. ? unwraps Ok or propagates Err upward. .map, .and_then, .unwrap_or — a whole combinator family —
operates on the Ok side. if let Ok is a sanctioned spelling for “only
the success continuation interests me.” Nothing in the language gives Err the same gravity; it gets #[must_use] on the container and a variant you
may or may not open.
So Result has a happy path. Not in the type — in the syntax, and in the
social contract that forms around it: Ok is the continuation you thread, Err is the continuation you may collapse, convert, or drop.
A tor has no happy path. Its declaration is a closed protocol of named
outcomes, and they are all peers:
pub tor read-file { path: string }
| contents { text: string, size: u64 }
| not-found
| permission-denied string
| ?!disk-full usize contents is not the good outcome and the others the bad ones — the
signature has no error slot, no privileged variant, no polarity at all. It
makes no value judgement on the branches, in either sense of value: no
verdict on which outcome is the good one, and no Result produced for a
verdict to attach to. not-found is not an error; it is an exit with a
name from the domain, answered by the same arrow as contents and carrying
the same obligation. “Error” is a reading you bring to a branch, not a
position in the protocol.
And the call site is not a value you unwrap and then a match you may write. The call site is the match:
read-file(path: "config.toml")
| contents c |> process(text: c.text)
| not-found |> create-default(path: "config.toml")
| permission-denied _ |> request-elevation() There is no value in the middle to hold, drop, or convert. You do not receive a result and then decide what to do — you thread through whichever named exit fired, and the handler for it is written where the call is.
(disk-full is deliberately unwritten at that call site — the ?! kind is
covered below; its unwritten arm is not silence.)
The comparison, in one line: Rust has errors as values. Koru has outcomes as control flow. If that sounds like exceptions, notice what is different: exceptions were errors-as-control-flow too — invisible control flow, dispatched at runtime to whoever happened to catch them. Rust moved errors out of invisible flow and into visible values. Koru puts them back in the flow — and keeps them visible: declared in the signature, named at the call site, closed at compile time.
Exhaustiveness is the product, not a lint
Drop the permission-denied arm from that call site and the program does not
compile. KORU022. Not a warning, not a lint you can allow — the flow
checker treats the missing arm the way a missing argument is treated: the
program is incomplete.
The consequence that matters: adding a branch is an API break. When read-file grows | timeout, every call site stops compiling until the new
outcome is answered by name. That is deliberate — it is the feature, not a
harshness to be softened into “we recommend handling all cases.” The
protocol is closed; extending it is a contract change, and the compiler
delivers the invoice to every caller.
Rust has exhaustiveness too — match on an enum must cover it. The
difference is where the check lives. Rust’s exhaustiveness is a property of match expressions: it fires when you choose to match, on the enum you
chose to match. Result<T, E>’s check covers Ok and Err, and Err(_) satisfies it completely — everything inside E is a payload you may inspect
or not. Koru’s check is a property of the call: every non-optional branch
in the signature must appear at the call site, by name, payload or not.
Discard is a mention, not an exemption
You can decline a payload and do nothing with a branch. You just have to say so:
read-file(path: "config.toml")
| contents c |> process(text: c.text)
| not-found |> _
| permission-denied _ |> _ | permission-denied _ |> _ is a decision record. Three positions, all
occupied: the branch is named (permission-denied), the payload is dropped (_ in binding position), the handler is empty (_ as the
body). Compare it with omission — which is not a softer version of this but
a compile error. Omission is not seeing it; named discard is refusing it.
This is what Err(_) => {} wishes it were. The Rust arm looks handled —
an arm exists — but nothing in it says which failure was declined or that
the payload was seen and dropped on purpose. The Koru line says all three,
in tokens a reviewer can read and a grep can find.
The wildcard you cannot write
Here is how ML-family languages undo their own exhaustiveness: they give
one token two jobs. In F#, _ means “unused field” and “remaining
cases”:
match result with
| Ok c -> process c
| _ -> () // every Err, present and future, gone The second use is a union wildcard — it swallows the sum. Exhaustiveness is
still “checked”; it is just that _ checks out for everything. The escape
hatch is built from the same spelling as the honest discard, so refusing
one outcome and refusing all outcomes are indistinguishable in source.
And the wildcard is worse at evolution time than at write time. Add | Timeout of int to the result type and this call site still compiles —
the _ written last year already “handles” a case nobody had seen. The
compiler cannot tell “I declined these cases on purpose” from “I have never
heard of this one,” because both are the same token. So the one change that should be loud — the protocol grew — lands silently, absorbed by a hole
that predates it. In Koru that same change is the API break from the last
section: | timeout arriving on read-file stops every caller until it is
answered by name. The wildcard is what makes the break silent.
Koru locks that door structurally. _ is not a legal name character at
all — a tor named read_file is rejected (KORU034); Koru names are
kebab-case, and _ is reserved for digit separators and for the discard
itself.
So _ survives in exactly two positions, and both come after the branch
has been named: the payload binding and the handler body. There is no
spelling for “remaining cases” in the union-wildcard sense. | _ |> _ cannot be formed — the first _ is not a name, so it is not a branch.
Koru does have catch-alls — and they are narrower than the wildcard they
replace. |? reaches only branches the signature marked optional, and it
must engage: bare |? |> _ is rejected as pay-for-nothing, because routing
unhandled branches into a no-op costs runtime for zero information.
Same lesson from both directions: you may not write _ where a name
belongs, and you may not write a catch-all that does nothing. The only
“everything else” that exists is scoped to branches the author already
declared skippable — and it has to earn its keep.
Three kinds of branch, none of them “skip the line”
The caller-facing discipline only works because the declaration carries the variance. Three branch kinds, three different meanings of “may I omit the arm”:
- Required (
| err) — omit the arm, getKORU022. The signature is a closed protocol. - Optional (
| ?warning) — the author’s ruling that this outcome is semantically skippable: a caller who ignores it is missing nothing that matters, and a fire is a silent no-op. It is a declaration of intent, not a compatibility shim — when a new outcome does matter, it goes in required, and breaking every caller is not the cost of growing the protocol. It is the purpose. - Panic (
| ?!oom) — you may decline the arm; the compiler writes a loud one in your place. A fire stops the program and names the branch. Recovery is opt-in; the crash surface stays in the type.
And the same three kinds exist on the effect side — resumable exits where
control can come back, marked ! instead of |: required ! token,
optional ! ?warning, panic ! ?!stall, plus their own engaging catch-all
(!?). The panic marker is always ?!, on whichever side it rides. There
is no | ! spelling, and the absence is the design: a panic the caller may
not decline is not a fourth kind of outcome — it is a stance the checker
takes over the whole program, which is what --panic-branches=strict is
for. ! at line start stays the effect introducer; the kind-space stays
two sides by three kinds.
Notice what this is not. Optional is not a sneaky if let at the call
site — the permission lives in the signature, where the author put it.
Panic is not silence — the unwritten arm is synthesized, not skipped. If
“skip the line” meant the same thing for all three, you would have rebuilt if let Ok with extra punctuation. The kinds stay distinct because the
decisions are distinct.
when refines; it does not cover
A guarded arm narrows a branch; it does not satisfy it:
read-file(path: "big.bin")
| contents f when f.size > 5000 |> spool(f)
| contents f |> process(text: f.text)
| not-found |> _
| permission-denied _ |> _ The first contents arm is conditional. The second is the close — the
unguarded sibling that makes the branch covered. Remove it and the guard’s
false case has nowhere to go, so the flow is rejected: a required branch
whose only handlers are when-guarded is incomplete coverage. The
compiler does not try to prove your guards complementary — size > 5000 plus size <= 5000 is still two conditions, not a proof — because that
proof is the halting problem wearing a mustache.
The rule is one line: a guarded arm counts as zero constructors for exhaustiveness. An unguarded arm — a real handler, a named discard, or a panic arm — closes the branch.
Composition is the cost you accept
Here is the honest concession first. There is no public Result type, so
there is no ? operator, no map combinator, no From impl to collapse
error types into one another. What replaces them is not one mechanism but
two — and only the second is a tax.
The first is the thread. When each stage of a chain leaves exactly one
branch unclaimed, that branch threads into the next stage’s input — by
name, because punning names the hole. This is not a sketch; it is the
compiler’s own analysis pass, verbatim from koru_std/compiler.kz:
analysis = check-release-gate
|> check-structure
|> check-phantom-signatures
|> check-phantom-args
|> pass-auto-discharge
|> check-flow
|> check-phantom-semantic
|> check-purity
| failed f => failed { f.ctx, f.message } Eight tors, one context threaded through all of them, zero binders — the
compiler compiles itself through this chain. The dedented | failed arm
is the choke: it claims the failed branch for the whole region above,
which is the job ? does — except it claims by name, and it could claim
any name. ? can only propagate the designated error slot; a choke can
claim ctx and let failed thread, if that is the shape you want. The
thread follows arity — the sole unclaimed branch — never polarity. There is
no success slot being silently propagated; there is a named branch being
claimed and a survivor going forward. And the claiming is total: a branch
that is neither claimed nor the survivor is a compile error, so nothing
propagates that wasn’t routed.
(The full account of how the six-deep pyramid flattened into this pipeline is its own post.)
The second mechanism is the map, and this is where the tax lives. When the inner vocabulary is not the outer’s — different names, different granularity — you write the routing branch by branch:
pub tor step {}
| return
| break
| continue
pub tor run {}
| stopped
| iterated
run = step()
| return => stopped
| break => stopped
| continue => iterated Every inner branch is routed to a declared outer exit, in source, where you
can read it. There is no catch-all forward — you cannot write | _ => anything, for the same reason you cannot write | _ |> anything.
Forwarding five branches costs five written arms. That is more keystrokes
than ?, every time.
What the tax buys is what ? throws away: provenance at every level. From<DbError> for AppError erases where a failure came from; anyhow erases it wholesale. The choke names the branch it claims; the map names
every route. And look at what propagates in the analysis chain — failed { f.ctx, f.message } still carries the context that produced it.
Nothing was flattened into a generic E on the way up. The cost is real
and it is the price of the audit trail, not an accident of a missing
feature.
What you can actually grep
Rust’s audit greps for unwrap because unwrap is the only failure token
guaranteed to be spelled. Everything else — .ok(), Err(_), if let Ok, let _ = — is a way to make an outcome leave no token behind.
Koru’s audit works because the tokens are mandatory, not conventional:
grep not-foundfinds every call site that answered that outcome — and the compiler guarantees the answer is there to find.grep "| not-found |> _"finds the places that declined it on purpose.grep "|?"finds the engaged catch-alls and nothing else — there is no quieter form that also parses.grep "?!"finds the crash surface, in declarations; under--panic-branches=strictthe same surface becomes compile errors at the call sites.
Grepability was never a slogan. It falls out of names that cannot be omitted — you can search for a decision because the decision had to be written.
The lineage, and the sentence
None of this is a unique invention, and it should not read as one.
Exhaustive sums are ML’s from the seventies; OCaml and F# kept them and
kept the wildcard that quietly empties them. Multiple named exits are
older than most languages discussed here. Algebraic effects gave the world
resumable, named operations with typed signatures — Koru’s ! branches are
that idea wearing different clothes. Rust’s Result is a good
two-continuation type with a social happy path bolted on; the bolt is what ?, if let Ok, and Err(_) are for. Koru’s contribution is smaller and
meaner: it removed the bolt.
That is the comparison in one line: Rust has errors as values; Koru has outcomes as control flow. And the second half decomposes into the sentence this post stands on: control flow is in the protocol; partial handling is a type error; the only legal ignore still spells the outcome.