hanki

7. Errors

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

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.

[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

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.)

with a sibling arm's real value (as above).

enclosing function.

normal return, just earlier. crash! remains the only non-normal exit.

unreachable match arm (§8).

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.