Error Handling
Saga has no exceptions. Error handling uses ordinary values (Result) and the
effect system (Fail), and the two work together.
The Fail Effect
Fail indicates that something went wrong. The function calling fail!
doesn't decide what happens next -- the handler does.
fun find_user : Int -> User needs {Fail, Database}
find_user id = case (db_lookup! id) {
Just user -> user
Nothing -> fail! $"user {id} not found"
}find_user says "I might fail" through its needs {Fail} clause, but it
has no opinion about what failure means. That's up to whoever handles it.
Aborting with to_result
The most common handler for Fail aborts the computation and wraps the
outcome in a Result:
handler to_result for Fail {
fail reason = Err reason
return value = Ok value
}
let result = find_user 42 with to_result
case result {
Ok user -> $"found: {user.name}"
Err msg -> $"error: {msg}"
}When fail! is called, the handler doesn't call resume, so the computation
stops. The return clause wraps the success path in Ok so both paths produce
the same Result type.
Resuming with a default value
A handler can also choose to recover and keep going. Since fail! returns
-> a, the handler can resume with any value that fits:
find_user 42 with {
fail _ = resume default_user
}Here the computation doesn't abort. fail! returns default_user at the
call site and execution continues as if nothing went wrong. The same effect,
different handler, completely different behavior.
Result vs Fail
Both represent operations that can fail. Result is a value you pattern match
on. Fail is intended for flow control: it transfers execution to a handler.
Use Result when you want the caller to inspect the outcome immediately. Use
Fail when you want failures to propagate up to a handler boundary without
manual forwarding at each step.
The two convert easily. Lifting a Result into Fail:
fun parse_and_validate : String -> Int needs {Fail}
parse_and_validate s = case (parse_int s) {
Ok n -> n
Err e -> fail! e
}Going the other direction, to_result converts a Fail computation back
into a Result at whatever boundary makes sense.
panic and todo
panic and todo are language builtins that crash the process immediately.
panic "this should never happen"
todo ()Both functions unify with any type, so they work in any expression position without a type error. Unhandled, they print to stderr and exit with code 1.
Use panic for logic errors and genuinely unreachable code. Use todo for
unfinished code during development. Neither is appropriate for recoverable
errors: use Fail or return a Result type.
catch_panic: Recovery Boundaries
For cases where you need to protect a boundary from an unexpected crash,
catch_panic runs a function and returns Ok value on success or
Err message on panic:
import Std.Process (catch_panic)
case catch_panic (fun () -> handle_request req) {
Ok resp -> resp
Err msg -> {
log! $"request panicked: {msg}"
error_response 500
}
}This is not for error handling -- use Fail for recoverable errors. catch_panic
exists for two specific cases:
- Process protection: preventing one bad request from crashing a server loop
- Testing: asserting that a function panics when it should
test "head of empty list panics" {
assert_panics (fun () -> List.head [])
}Effects work normally inside the thunk. Handlers from the surrounding scope are captured by the lambda, so effectful code runs as expected.
Process Control
For explicit exit codes, Std.Process provides two functions:
import Std.Process
Process.exit 0 # immediate halt, success
Process.exit 1 # immediate halt, failure
Process.shutdown 0 # graceful BEAM VM shutdown (flushes I/O, runs shutdown hooks)Both return -> a and can appear anywhere a value is expected. Use
Process.exit when you want to halt immediately. Use Process.shutdown when
you want the VM to clean up first.
Supervision
For long-running processes, the right response to a failure is often to restart
rather than propagate. Because Fail is just an effect, supervision is a
handler that catches failures and re-runs the computation. This is covered in
full in Supervision.