Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Error Handling

Kāra has no exceptions. No try/catch. Errors are values, handled explicitly through Result and Option.

This sounds strict, but in practice it makes error handling easier — the compiler tracks which operations can fail and makes sure you handle them.

Result and the ? operator

An operation that can fail returns Result[T, E]:

fn parse_port(s: String) -> Result[u16, ParseError] {
    // returns Ok(port) or Err(error)
}

The ? operator propagates errors to the caller:

fn load_config(path: String) -> Result[Config, Error] {
    let text = read_file(path)?;       // if Err, return it immediately
    let config = parse_toml(text)?;     // same here
    Ok(config)                          // success
}

Without ?, you'd need a match at every step. ? keeps the happy path clean.

Option and ?

? also works with Option:

fn first_word(text: Option[String]) -> Option[String] {
    let t = text?;                     // if None, return None
    let words = t.split(' ');
    words.first()
}

Optional chaining

For navigating nested optional values:

let city = user.address?.city?.name;

If any step is None, the whole expression short-circuits to None.

Default values with ??

let name = user.nickname ?? "anonymous";
let port = parse_port(input) ?? 8080;

?? provides a fallback when the left side is None or Err. The fallback is evaluated lazily — only when needed.

Adding context to errors

The ? operator records where an error traveled. .context() adds why:

fn process_user(id: u64) -> Result[Report, Contextual[AppError]] {
    let user = db.find(id)
        .context(f"while loading user {id}")?;
    let orders = db.orders_for(user.id)
        .context(f"while fetching orders for user {id}")?;
    build_report(user, orders)
}

This builds a chain of context that helps debugging: you see both the original error and the high-level operation that failed.

unwrap — the escape hatch

unwrap() extracts the value from Option or Result, crashing if it's None or Err:

let value = some_option.unwrap();   // panics if None

This produces the panics effect — the compiler tracks it through your call chain. Public functions that can panic must declare it. For production code, prefer ?, ??, unwrap_or(default), or match.

unwrap() is useful in tests, prototypes, and cases where you've already validated the value can't be None/Err.

Cleanup with defer and errdefer

defer runs cleanup when a scope exits, regardless of how:

fn process_file(path: String) -> Result[Data, Error] {
    let file = open(path)?;
    defer file.close();          // always runs when scope exits

    let data = parse(file)?;     // if this fails, file still gets closed
    Ok(data)
}

errdefer runs cleanup only on the error path:

fn open_connection(addr: String) -> Result[Connection, Error] {
    let conn = Connection.open(addr)?;
    errdefer conn.close();       // only if we return Err below

    register_metrics(conn)?;     // if this fails, close the connection
    Ok(conn)                     // success: errdefer does NOT run
}

Multiple defer/errdefer blocks run in reverse order (last declared, first executed).

The error handling philosophy

Kāra's approach comes down to:

  • Errors are data. They flow through the type system like any other value.
  • The compiler enforces handling. You can't silently ignore a Result.
  • ? makes the happy path clean. Error propagation is one character, not five lines of boilerplate.
  • defer handles cleanup. No RAII gymnastics, no finally blocks — just "run this when we leave."

The result is code where the error paths are visible but not noisy.