hanki

7. Errors

Failure splits in two along the pure and action line (§5, §6):

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:

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

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.