๐Ÿฆ€ Functional Rust

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

Key Differences

ConceptOCamlRust
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-raiseNew `perform (Exn ...)` inside handler โ€” starts new effect`Err(transformed_msg)` or `comp_raise(new_msg)` in handler
Relationship to effectsExceptions 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