7. Errors
Failure splits in two along the pure/action line (§5, §6):
- Computed/logical failure in pure code is a *value* -
Result<T, E>or
Option<T>, returned and matched. A pure function is total (§6): it never crashes and never performs an effect, so it cannot throw. A parse that may fail, a smart constructor that may reject its input, 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 that are inseparable from touching the world: an io error, an actor [throws actor.SendFailed]. throws stays an effect on actions only; pure functions (empty effect row) cannot carry it.
This is the Functional Core / Imperative Shell split, compiler-enforced: the shape of a function's failure 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 carries the error payload that Option drops. A pure fallible function returns it; 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. Sequencing several Result/Option values is plain match. When chaining fallible steps starts to nest, the answer is value combinators - and_then flattens a chain of fallible lookups (e.g. walking a json document, §17), or_else expresses a fallback chain (a().or_else(|| b()).or_else(|| c()); on Result the callback receives the error and may change its type) - not new control-flow syntax.
The failure surface is closed, by design and for good: match + return + value combinators for pure values, throws + try/catch (§6) for effects. Hanki has no dedicated propagation syntax and will add none - if a combinator chain ever grows unwieldy the remedy is a better combinator (an ordinary library method, addable at any time), never a new language form. Combinators grow; the syntax surface stays frozen.
For the guard-clause shape - validate inputs, bail early on the bad ones - Hanki provides an explicit return (below). The Rust-style postfix ? stays rejected, and with it the other implicit-exit forms (a <- do-block, a keyword-free let-else): each hides the exit at the jump site. return is the opposite - a keyword in statement/tail position, so every early exit stays visible on the page, and it extends the family Hanki already has (break / continue) rather than adding a hidden-jump category.
Early return - return
return <expr> exits the enclosing def / def! / handler / closure with <expr>; a bare return exits a ()-returning function. It is the explicit, visible counterpart to those rejected implicit-exit forms - a keyword in statement/tail position, never a hidden jump - and it keeps the guard-clause pattern flat instead 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/tail position only. Legal as a statement, a block tail, an
if/elif/else branch, or a match-arm body - never a strict sub-expression: 1 + return x and f(return x) are compile errors. (The useful x = if c\n return Err(..)\nelse\n v\nend is fine - the return is a branch-block tail.)
- Bare
returnonly in()functions; elsewhere a value is required. - Typed
Never(§4), so areturnarm coerces to any type and unifies
with a sibling arm's real value (as above).
- Closure-local:
returninside a closure exits that closure, not the
enclosing function.
- Effect-free and still total (§6): a pure
defmay use it - it is a
normal return, just earlier. crash! remains the only non-normal exit.
- Dead code after
returnin the same block is a compile error, like an
unreachable match arm (§8).
- Tail
returnis redundant - a compile error. Areturnin tail
position (the block's last expression, including a tail if/elif/else branch or a tail match arm) duplicates the implicit last-expression return; drop it and let the value fall out. return reads as early exit, so it is reserved for non-tail (guard) positions: if c\n return a\nend\n b is fine; if c\n return a\nelse\n return b\nend flags 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]. To handle it, use 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]. 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 carry 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; 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 between variants with a match inside the arm. A throw whose type isn't named by any arm continues propagating up to the enclosing try or the surrounding action's declared [throws E].
A note on the cost: implicit propagation plus universally-injected throws (e.g. every actor send picks up [throws actor.SendFailed]) make signatures grow as effects flow through them. This is the same shape that earned Java's checked exceptions their reputation, and the criticism is real. Two things make the trade-off come out differently here. First, the propagation is information - throws actor.SendFailed on a handler marks it as a transitive participant in the actor system, making Functional Core / Imperative Shell compiler-enforced rather than a matter of discipline. Second, the language assumes AI-assisted development: mechanical migration when a new effect appears downstream is near-free, so the human-coping anti-patterns that killed Java's version (throws Exception everywhere, catch-and-swallow) don't get a foothold. Polymorphic effect vars (§6) further reduce per-layer enumeration once generic actions are involved.