191: Effect Handlers for Exceptions
Difficulty: โญโญโญโญโญ Level: Expert Model exceptions as a special case of algebraic effects โ an effect that the handler catches without resuming, equivalent to Rust's `Result<T, E>` but demonstrating the connection between exception handling and the effect system.The Problem This Solves
Exceptions and algebraic effects are usually taught separately: exceptions are a control flow primitive built into the runtime, while effects are a functional abstraction. But exceptions are actually a restricted form of algebraic effects โ specifically, effects whose handler does not resume the computation. Understanding this connection deepens your mental model of both. In OCaml 5, you can model `try/catch` entirely with effects: `effect Exn : string -> 'a` raises an exception-like signal, and a handler catches it with `| effect (Exn msg) _ -> handler msg` โ the `_` discards the continuation, making it non-resumable, just like a real exception. This shows that exceptions are not fundamental โ they're a pattern within the effect system. In Rust, we already have `Result<T, E>` which is algebraically equivalent. `Result` is the "exception effect" specialized for Rust's ownership model. This example shows both approaches: idiomatic `Result`-based error handling, and a free-monad simulation of the `Exn` effect, demonstrating that they're the same pattern at different levels of abstraction.The Intuition
Normal algebraic effects are like phone calls: the caller performs an effect (dials), the handler picks up and helps, then resumes the caller (hangs up and the caller continues their day). Exceptions are like fire alarms: someone raises an exception, the building empties, and there's no "resuming where you left off." The continuation is discarded. `Result<T, E>` is Rust's fire alarm system. `Err(e)` propagates up through `?` operators, handlers match on it, and there's no going back to the point where the error occurred. The free-monad `Comp<A>` in this example makes that propagation explicit: `Raise` short-circuits every `bind` downstream, bubbling up exactly like an exception, until a `run_comp` handler catches it.How It Works in Rust
// Approach 1: Result<T, E> โ idiomatic Rust, equivalent to an Exn effect
fn safe_div(a: i32, b: i32) -> Result<i32, String> {
if b == 0 { Err("division by zero".to_string()) }
else { Ok(a / b) }
}
// try_with mirrors OCaml's effect handler: f() runs; if Err, handler catches it
fn try_with<A, F: FnOnce() -> Result<A, String>, H: FnOnce(String) -> A>(f: F, h: H) -> A {
match f() { Ok(v) => v, Err(msg) => h(msg) }
}
let result = try_with(|| safe_div(10, 0), |msg| { println!("caught: {}", msg); -1 });
// Approach 2: Free-monad simulation โ models the Exn effect explicitly
enum Comp<A> {
Done(A),
Raise(String), // the exception โ no continuation stored
Step(Box<dyn FnOnce() -> Comp<A>>),
}
// bind propagates Raise automatically โ just like ? operator
fn comp_bind<A: 'static, B: 'static, F: FnOnce(A) -> Comp<B> + 'static>(
comp: Comp<A>, f: F,
) -> Comp<B> {
match comp {
Comp::Done(x) => f(x),
Comp::Raise(e) => Comp::Raise(e), // short-circuit: f never called
Comp::Step(thunk) => Comp::Step(Box::new(move || comp_bind(thunk(), f))),
}
}
fn run_comp<A, H: FnOnce(String) -> A>(comp: Comp<A>, handler: H) -> A {
let mut current = comp;
loop {
match current {
Comp::Done(x) => return x,
Comp::Raise(msg) => return handler(msg), // handler catches, no resume
Comp::Step(thunk) => current = thunk(),
}
}
}
// Key behavior: Raise in first step skips ALL subsequent steps
let chained = comp_bind(comp_safe_div(100, 0), |x| comp_safe_div(x, 4));
// 100/0 raises โ comp_safe_div(x, 4) is NEVER called
let result = run_comp(chained, |msg| { println!("caught: {}", msg); -1 });
// result = -1
// Annotated handler: wraps the error with context, then re-raises
fn annotated_div(a: i32, b: i32) -> Result<i32, String> {
safe_div(a, b).map_err(|e| format!("annotated_div({}, {}): {}", a, b, e))
}
The crucial difference from resumable effects: when `Comp::Raise` matches in `bind`, `f` is discarded. There's no continuation to resume. This is exactly how OCaml's `effect Exn _ -> handler msg` works โ the `_` drops the continuation.
What This Unlocks
- Understanding `?` operator โ the `?` operator is syntactic sugar for the Exn effect: `Raise` propagates, `Done` continues. Seeing it as an effect handler makes the semantics crystal clear.
- Custom error propagation strategies โ the `Comp` approach lets you write interpreters that log every error, transform errors as they propagate, or implement retry logic at the effect level.
- Layered error handling โ annotated handlers add context at each layer (`annotated_div` wraps the inner error), demonstrating how nested effect handlers compose โ exactly how `anyhow::Context` works.
Key Differences
| Concept | OCaml | Rust | |
|---|---|---|---|
| Raising an exception | `perform (Exn "msg")` โ effect with no useful return type | `Err("msg")` with `?`, or `comp_raise("msg")` in free-monad style | |
| Non-resumable handler | `\ | effect (Exn msg) _ -> handler msg` โ `_` discards continuation | `Comp::Raise(e)` in `bind` discards `f`; `run_comp` calls handler without continuation |
| Automatic propagation | `perform` bubbles through all intermediate frames until a handler | `?` operator on `Result`; `Comp::Raise` propagates through every `comp_bind` | |
| Handler re-raise | New `perform (Exn ...)` inside handler โ starts new effect | `Err(transformed_msg)` or `comp_raise(new_msg)` in handler | |
| Relationship to effects | Exceptions are effects where handler discards `k` | `Result` is the idiomatic equivalent; `Comp<A>` makes the free-monad structure explicit |
// Effect Handlers for Exceptions
//
// OCaml 5 effects can model exceptions where the handler does NOT resume.
// In Rust this maps naturally to Result<T, E>.
//
// We also show the "effect interpreter" version: a computation that can
// "perform" an Exn effect, and the handler either catches it or propagates.
// โโ Approach 1: Result<T,E> โ the Rust-idiomatic exception effect โโโโโโโโโ
fn safe_div(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err("division by zero".to_string())
} else {
Ok(a / b)
}
}
fn try_with<A, F: FnOnce() -> Result<A, String>, H: FnOnce(String) -> A>(
f: F,
handler: H,
) -> A {
match f() {
Ok(v) => v,
Err(msg) => handler(msg),
}
}
// โโ Approach 2: Free-monad style with an Exn effect โโโโโโโโโโโโโโโโโโโโโโโโ
//
// A computation step is either Done(value), Raise(msg), or
// Step(thunk -> next). The interpreter catches Raise.
enum Comp<A> {
Done(A),
Raise(String),
Step(Box<dyn FnOnce() -> Comp<A>>),
}
fn comp_return<A>(x: A) -> Comp<A> {
Comp::Done(x)
}
fn comp_raise<A>(msg: impl Into<String>) -> Comp<A> {
Comp::Raise(msg.into())
}
fn comp_bind<A: 'static, B: 'static, F: FnOnce(A) -> Comp<B> + 'static>(
comp: Comp<A>,
f: F,
) -> Comp<B> {
match comp {
Comp::Done(x) => f(x),
Comp::Raise(e) => Comp::Raise(e),
Comp::Step(thunk) => Comp::Step(Box::new(move || comp_bind(thunk(), f))),
}
}
/// Run a computation; if it raises, call the handler function.
fn run_comp<A, H: FnOnce(String) -> A>(comp: Comp<A>, handler: H) -> A {
let mut current = comp;
loop {
match current {
Comp::Done(x) => return x,
Comp::Raise(msg) => return handler(msg),
Comp::Step(thunk) => current = thunk(),
}
}
}
fn comp_safe_div(a: i32, b: i32) -> Comp<i32> {
if b == 0 {
comp_raise("division by zero")
} else {
comp_return(a / b)
}
}
// โโ Approach 3: catch_unwind style โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Demonstrate nested handlers: an inner handler re-raises with context
fn annotated_div(a: i32, b: i32) -> Result<i32, String> {
safe_div(a, b).map_err(|e| format!("annotated_div({}, {}): {}", a, b, e))
}
fn main() {
// โโ Result-based โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let r1 = try_with(|| safe_div(10, 2), |_| -1);
println!("10/2 = {}", r1);
let r2 = try_with(|| safe_div(10, 0), |msg| {
println!("caught: {}", msg);
-1
});
println!("10/0 handled = {}", r2);
// โโ Free-monad effect style โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let r3 = run_comp(comp_safe_div(10, 2), |_| -1);
println!("comp 10/2 = {}", r3);
let r4 = run_comp(comp_safe_div(10, 0), |msg| {
println!("comp caught: {}", msg);
-1
});
println!("comp 10/0 handled = {}", r4);
// โโ Chained: divide twice โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let chained = comp_bind(comp_safe_div(100, 5), |x| comp_safe_div(x, 4));
println!("100/5/4 = {}", run_comp(chained, |_| -1));
let chained_err = comp_bind(comp_safe_div(100, 0), |x| comp_safe_div(x, 4));
println!("100/0/4 = {}", run_comp(chained_err, |msg| {
println!("chained caught: {}", msg);
-1
}));
// โโ Annotated โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
println!("{:?}", annotated_div(5, 0));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_safe_div_ok() {
assert_eq!(safe_div(10, 2), Ok(5));
}
#[test]
fn test_safe_div_err() {
assert!(safe_div(10, 0).is_err());
}
#[test]
fn test_try_with_catches_error() {
let r = try_with(|| safe_div(10, 0), |_| 99);
assert_eq!(r, 99);
}
#[test]
fn test_comp_safe_div_ok() {
let r = run_comp(comp_safe_div(12, 3), |_| -1);
assert_eq!(r, 4);
}
#[test]
fn test_comp_safe_div_raises() {
let mut caught = String::new();
let r = run_comp(comp_safe_div(12, 0), |msg| {
caught = msg;
-1
});
assert_eq!(r, -1);
assert!(caught.contains("division by zero"));
}
#[test]
fn test_comp_bind_propagates_raise() {
// The error in the first step should skip the second step
use std::rc::Rc;
use std::cell::Cell;
let second_called = Rc::new(Cell::new(false));
let sc2 = second_called.clone();
let comp = comp_bind(comp_safe_div(10, 0), move |_| {
sc2.set(true);
comp_return(999_i32)
});
let r = run_comp(comp, |_| -1);
assert_eq!(r, -1);
assert!(!second_called.get());
}
}
effect Exn : string -> 'a
let try_with f handler =
match f () with
| v -> v
| effect (Exn msg) _ -> handler msg
let safe_div a b =
if b = 0 then perform (Exn "division by zero")
else a / b
let () =
let r1 = try_with (fun () -> safe_div 10 2) (fun _ -> -1) in
Printf.printf "10/2 = %d\n" r1;
let r2 = try_with (fun () -> safe_div 10 0) (fun msg -> Printf.printf "caught: %s\n" msg; -1) in
Printf.printf "10/0 handled = %d\n" r2