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.

· 11 min read

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, get KORU022. 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-found finds 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=strict the 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.