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