7. Errors
Failure splits in two along the pure and action line (§5, §6):
- Computed failure in pure code is a value:
Result<T, E>orOption<T>, returned and matched. A pure function is total (§6). It never crashes and never performs an effect, and it cannotthrow. A parse that may fail, a smart constructor that may reject its input, and a lookup that may miss all return a value the caller inspects. - World-coupled failure in the imperative shell is an effect:
[throws E], for failures inseparable from touching the world, anioerror or an actor[throws actor.SendFailed].throwsis an effect on actions alone, and a pure function with an empty effect row cannot have it.
This is the Functional Core and Imperative Shell split, compiler-enforced: a function's failure form tells you whether it touches the world.
Result<T, E>: failure as a value
Result<T, E> is the core sum Ok(T) | Err(E) (§4). It has the error payload Option drops. A pure fallible function returns it, and the caller matches:
use io
open result
opaque Email
value: string
where
value.contains?("@") else "email must contain @"
end
def accept(e: Email) -> string
e.value
end
def check!(raw: string) -> () [io]
match Email.new(raw) # pure; returns Result<Email, validation.ValidationError>
Ok(e) -> io.print!("#{accept(e)}\n")
Err(v) -> io.print!("rejected: #{v}\n")
end
end
result ships unwrap_or, map, map_err, and_then and or_else; option ships unwrap_or, map, and_then and or_else. Every one of those that takes a callback also has an effect-polymorphic ! twin, option's map!/and_then!/or_else! and result's map!/map_err!/and_then!/or_else!, on the same terms as List's below: the [e] row solves to whatever the callback performs, and an effectful step over an Option or a Result needs no hand-written match. Laziness is unchanged, and it is what makes the effectful forms useful. map! and and_then! perform only on Some and Ok, and or_else! and map_err! only on None and Err. unwrap_or takes a value and no callback, and has no twin.
Both types also ship or_crash!(msg), the one partial method on either type and the only stdlib API with [Crash]. It returns the wrapped value, and on Err or None it crash!es with msg, killing the actor and exiting 252 at the program root (§15). There is no unwrap and no expect. or_crash! always takes the message: what a reader needs at the crash is which path failed, and only the caller knows that. Its own effect row scopes it, in place of convention. [Crash] is viral, H0601 charging every caller up the chain to declare it, and no pure def may call it at all (H0606). It is therefore unusable from library code meaning to stay pure and nearly free in a script whose main! is already an action. The shortcut exists where crashing is the right answer, and nowhere else.
Sequencing several Result or Option values is plain match. Where chaining fallible steps starts to nest, the answer is value combinators: and_then flattens a chain of fallible lookups, such as walking a json document (§17), and or_else expresses a fallback chain, a().or_else(|| b()).or_else(|| c()), where on Result the callback receives the error and may change its type. No new control-flow syntax is added for it.
The failure surface is closed to implicit exits: match, return and value combinators for pure values, throws with try/catch (§6) for effects. The Rust-style postfix ? is rejected, and with it the <- do-block. Each takes the exit off the page, and a reader scanning a body cannot see where it can leave. Where a combinator chain grows unwieldy the first remedy is a better combinator, an ordinary library method addable at any time, and never a form that hides a jump.
The guard form: a refutable binding with a divergent else
One dedicated form covers the case combinators do not reach: a binding whose pattern may fail, followed by an else arm that must diverge.
open result
open option
type StoreError
NotFound
end
def first(names: List<string>) -> Result<string, StoreError>
Some(name) = names.get(0) else None -> return Err(NotFound)
Ok(name)
end
def widest(sizes: List<int>) -> Option<int>
Some(head) = sizes.get(0) else None -> return None
Some(sizes.fold(head, |a: int, b: int| if a > b then a else b))
end
It is no ? under another name, and that difference is what made it admissible: every exit is still spelled where it happens, with a real return, throw or crash! on the line. What ? removes is the spelling of the exit; the guard form removes the surrounding match and no more.
The surface:
- A bare pattern on the left, with no new keyword, consistent with Hanki's ordinary
x = exprbindings. Hanki has nolet. - The
elsearm is a match arm,else PATTERN -> BODY, and it binds the failure payload. Rust'slet-else cannot, and forwardingeis the job. - Statement position only.
- The
elsebody must diverge (return,throw,break,continue,crash!), checked by the flow-narrowing rule the early-exitifalready uses. That is what lets the binding below take the success payload unconditionally. - Coverage is checker-verified. The left pattern plus the
elsepattern must cover the scrutinee's sum. A sum whose variants each want their own treatment is amatch; the guard form is for the two-outcome case. - No
varform. Rebind afterwards where the binding needs to be mutable. hanki fmtleaves the statement on one line, like a match arm, with no width-driven break. The one thing that breaks it is a comment between the->and theelsebody, which drops onto its own indented line, as it does in amatcharm and for the same reason: anywhere else the comment would land on something it does not describe.- The binding is a new one, running to the end of the block the guard sits in. It does not stand in for a rebind of a name declared further out: turning
name = match …into a guard inside anifbranch, wherenamewas declared above theif, binds a freshnamefor that branch and leaves the outer one untouched. That case is amatch. - The left side takes every pattern except an as-pattern (
p@Ok(v)). The parser reads it as an expression first, and@is no expression operator. Bind the whole in its own step (p = r) and destructure below. Theelsepattern, read as a pattern from the start, does take one.
The compiler reads the form as sugar. The binding takes the rest of its block as the success arm of a two-arm match, several binders come out of one pattern, and nothing new reaches the runtime. Both rules above have their own diagnostic: a fall-through else is H0575, and an else that leaves a case uncovered is H0547 worded for the guard.
The guard clause, validating an input and bailing early on a bad one, is a different thing: a pattern of use, written with the return below.
Early return: return
return <expr> exits the enclosing def, def!, handler or closure with <expr>; a bare return exits a ()-returning function. It is the visible counterpart to the rejected implicit-exit forms, a keyword in statement or tail position and never a hidden jump, and it leaves the guard-clause pattern flat in place of nesting the happy path inside an else:
open result
struct User
name: string
age: i32
end
type FormError
Blank
Negative
end
def check_name(name: string) -> Result<string, FormError>
if name.length == 0
Err(Blank)
else
Ok(name)
end
end
def make_user(name: string, age: i32) -> Result<User, FormError>
n = match check_name(name)
Ok(n) -> n
Err(e) -> return Err(e) # bail; the arm is `Never`, unifies with `n`
end
if age < 0i32
return Err(Negative) # bail from an `if`-guard
end
Ok(User(name=n, age=age)) # happy path, un-nested
end
- Statement and tail position only. Legal as a statement, a block tail, an
if/elif/elsebranch, or amatch-arm body, and never a strict sub-expression:1 + return xandf(return x)are compile errors. The usefulx = if c\n return Err(..)\nelse\n v\nendis fine, thereturnbeing a branch-block tail. - Bare
returnonly in()functions. Elsewhere a value is required. - Typed
Never(§4), and areturnarm coerces to any type and unifies with a sibling arm's real value, as above. - Closure-local.
returninside a closure exits that closure, never the enclosing function. - Effect-free and still total (§6). A pure
defmay use it: it is a normal return, taken earlier.crash!remains the only non-normal exit. - Dead code after
returnin the same block is a compile error, like an unreachablematcharm (§8). - Tail
returnis redundant, and a compile error. Areturnin tail position, the block's last expression, including a tailif/elif/elsebranch or a tailmatcharm, duplicates the implicit last-expression return; drop it and let the value fall out.returnmarks an early exit and is reserved for non-tail guard positions.if c\n return a\nend\n bis fine, andif c\n return a\nelse\n return b\nendflags both.
throws E: failure as an effect
throws E is for the shell. It propagates implicitly through any action whose effect set contains [throws E]. Handle it with try/catch:
use io
@derive(Display)
struct ParseError
what: string
end
def parse_command!(line: string) -> () [io, throws ParseError]
throw ParseError(what=line)
end
def handle!(line: string) -> () [io]
try
parse_command!(line)
catch e: ParseError
io.print!("parse: #{e}")
end
end
throw expr raises an error of the type matching the surrounding [throws E]. A callable’s type arguments also substitute inside its throws payloads, including Self and associated error types. Only user-defined struct and type (sum) values can be thrown; primitives like i32 and string are rejected at compile time.
A try block may take multiple catch arms, one per error type:
use io
@derive(Display)
struct ParseError
what: string
end
@derive(Display)
struct NotFound
name: string
end
def parse_command!(line: string) -> () [io, throws ParseError, throws NotFound]
if line == ""
throw ParseError(what="empty")
end
throw NotFound(name=line)
end
def handle!(line: string) -> () [io]
try
parse_command!(line)
catch e: ParseError
io.print!("parse: #{e}")
catch e: NotFound
io.print!("missing: #{e}")
end
end
Arms are tried in source order, and the first arm whose declared type matches the runtime throw fires. A catch e: ErrorKind arm whose type is a sum binds the whole sum value; distinguish the variants with a match inside the arm. A throw whose type no arm names continues propagating to the enclosing try, or to the surrounding action's declared [throws E].
A note on the cost. Implicit propagation plus universally-injected throws, every actor send picking up [throws actor.SendFailed], make signatures grow as effects flow through them. This is the same pattern that earned Java's checked exceptions their reputation, and the criticism is real. Two things make the trade come out differently here. The propagation is information: throws actor.SendFailed on a handler marks it as a transitive participant in the actor system, and Functional Core and Imperative Shell is compiler-enforced in place of being a matter of discipline. And the language assumes AI-assisted development, where mechanical migration when a new effect appears downstream is near-free, and the human-coping anti-patterns that killed Java's version, throws Exception everywhere and catch-and-swallow, get no foothold. Polymorphic effect variables (§6) further reduce per-layer enumeration once generic actions are involved.