or_return with a mapping expression
Status: decided 2026-09-25 —
or_else, not theor_return <fn>form below. Built in both compilers (529fe940, tychoc14cb884a9) and specified in §14.6.1; 79 call sites migrated, shipped in 0.8.6. The body below is the proposal as measured, kept for the reasoning.
The decision
x := store.scan(db, t) or_else e: Err(Storage(e))
r := fetch(u) or_else _: false # in a fn that returns bool
On failure or_else binds the error and returns the value after the : from the enclosing function; on success it is the payload, like or_return. It won over or_return <fn> because the returned value is any expression of the function's return type, not only a mapped Err: the same form covers the ~40 sites that return false, -1 or None on failure from a function that does not return a Result, which a mapping function cannot. The binding is named in place, so the replace shape needs no lambda. It costs one keyword.
Counts, corrected: after fdda3c5a migrated the last 11 forwarding matches, 54 unwrap-and-leave match sites remain, 43 of them mapping the error (wrap or replace). The 34/25/17 split below predates that commit.
The measurement
A sweep of every match block in the tree whose only job is to unwrap an Ok and leave on an Err found 76 sites across 17 files. They split cleanly:
| shape | count | today |
|---|---|---|
Err(e): return Err(e) — same error, forwarded | 34 | or_return already does this — all 34 migrated in f6f1025a |
Err(e): return Err(Wrap(e)) — wrapped | 25 | no form exists |
Err(e): return Err(Fresh(...)) — replaced | 17 | no form exists |
So 42 sites remain that or_return cannot express, because §14.6 returns Err(err) *unchanged*. Every one of them changes the error, which is the whole point of a domain error type: a net.listen failure surfacing to a caller as a SrvErr, not as a NetErr.
For scale, the freeze has been broken three times, and packed — the first — was justified by 141 sites. This is 42.
What the 42 look like
match net.listen(host, port):
Ok(f): fd = f
Err(e): return Err(Listen(host, port))
Four lines. The middle one is the only one carrying information.
The proposal
or_return takes an optional expression whose value is a function from the callee's error type to the enclosing function's error type. On Err(e) the function is applied and Err(f(e)) is returned; on Ok(x) nothing changes.
No new syntax is required. Tycho already has function values and lambdas (Lambda ::= "fn" "(" LambdaParams? ")" ( "->" Type )? ":" Expr, §4.4), so both spellings below already parse:
fd := net.listen(host, port) or_return fn(e): Listen(host, port) # replace
b := store.read(path) or_return Storage # wrap, by name
The second form works wherever the new error is a one-argument constructor, which covers the 25 wrapping sites (Storage(e), Wal(e), Plan(e)). The lambda form covers the 17 that ignore e and build a fresh error from other variables in scope.
The grammar delta
ExprStmt ::= ( Call | Call "or_return" Expr? ) NEWLINE
OrReturn ::= Unary "or_return" Expr?
or_return stays a postfix operator binding tighter than any binary operator, so f() or_return Wrap + 1 does not parse as a mapping of Wrap + 1 — the expression slot should be restricted to a primary, which is what the two spellings above need and nothing more.
What it costs
The feature touches both compilers. In the shipped one: lex.ty (no new token — the keyword exists), parse.ty (the optional expression), tcheck.ty (five existing or_return rules gain a mapped-error case), emit.ty (four sites), zsema.ty (the parallel for restriction is unchanged). src/tychoc.c carries 19 or_return sites that need the same treatment. Plus spec §14.6, a surface.lock entry is not needed — no new keyword — and fixtures for the refusals (a mapping whose type does not match the enclosing error type; a mapping on an Option, which has no error to map).
Three ways this could go, and what each gives up
- Build it as proposed. Covers all 42, subsumes the 34, and adds no keyword. The cost is real work in two compilers and one more thing in the language to learn.
- Build only the by-name form (
or_return Storage). Covers the 25 wrapping sites, is trivially simple to explain, and leaves the 17 replacement sites asmatchblocks. Smallest change that removes the most common shape. - Build nothing. The 42 stay as four-line
matchblocks. They are correct and readable; they are simply the most repeated shape in the tree, and the one a newcomer meets first when they write a program that can fail.
Recommendation
(1), and only when a program outside this repository wants it. The near-term roadmap is explicit that library and surface work is demand-gated — *"built against a real program that needs it, never ahead of one"*. 42 sites in the author's own code is a strong signal and not that trigger; the same standard that held packed to 141 measured sites should hold this one to a user who hits the wall. The measurement is recorded here so that when one does, the work is already scoped.