Handlers as Values
The handler { … } syntax evaluates to a handler value that can be stored,
passed as an argument, and reused. Installing a handler value is identical to an
inline block — handle expr with h — but avoids duplicating the clauses at every
call site. Each installation allocates a fresh frame, so abort still escapes only
from its own scope:
test_world fixes results; prod_world computes; the same roll_two() runs against both.
// Feature: handlers as first-class values — bind, pass, return, compose.
// Syntax: `handler { Effect::op(p) => body, ... }` evaluates to a handler
// value. Use it as the RHS of `handle expr with <h>`. Each install allocates
// a fresh frame, so terminal `abort(...)` arms still escape only their
// own scope.
// When to use: switching between prod / test / replay / dry-run handlers
// without duplicating the arm bodies inline at every call site.
effect Random {
fn next_int(lo: int, hi: int) -> int
}
effect Log {
fn info(msg: str)
}
fn roll_two() with Random + Log -> int {
let a = perform Random::next_int(1, 6)
let b = perform Random::next_int(1, 6)
perform Log::info("rolled {a} + {b} = {a + b}")
return a + b
}
// Deterministic handler — every roll comes out 4. Logs go to a list.
let test_log = []
let test_world = handler {
Random::next_int(_lo, _hi) => 4,
Log::info(m) => { test_log.push(m) },
}
// Production handler — picks within the requested range (using a counter
// here just so the example is reproducible without seeding state).
let counter = 0
let prod_world = handler {
Random::next_int(lo, hi) => {
counter = counter + 1
lo + counter % (hi - lo + 1)
},
Log::info(m) => print("[INFO] {m}"),
}
// Same body, two completely different worlds.
let test_total = handle roll_two() with test_world
print("test: {test_total}, logs captured: {test_log.len()}")
print("- {test_log[0]}")
// expected:
// test: 8, logs captured: 1
// - rolled 4 + 4 = 8
let prod_total = handle roll_two() with prod_world
print("prod: {prod_total}")
// expected (deterministic given counter starts at 0):
// [INFO] rolled 2 + 3 = 5
// prod: 5
The std::handler module offers combinators that operate on handler values:
compose(top, bottom)— merges two handlers into a single frame;topcovers its operations and delegates the rest tobottom.override(base, patch)—patchreplaces only the clauses it declares; the remaining ones stay withbase.record(h)— returns(h2, log):h2behaves exactly likehbut records each invocation inlogfor auditing or test assertions.panic_unhandled()— catch-all that panics if any uncovered operation arrives; useful at the base of acomposechain.
compose, override, record, and panic_unhandled in sequence over the same Log effect.
// Feature: handler combinators — small utilities over handlers-as-values
// from `std::handler`:
// compose(top, bottom) install top over bottom (one merged frame)
// override(base, patch) patch replaces matching arms in base
// record(h) → (h2, log) h2 calls h and records each invocation
// panic_unhandled() catch-all that panics on any op
// When to use: layering, defaults, audit/replay logs, fail-loud fallbacks
// — anywhere you would otherwise hand-write a wrapper handler literal.
use std::handler
use std::Array
effect Log {
fn info(msg: str)
fn warn(msg: str)
}
fn work() with Log {
perform Log::info("starting")
perform Log::warn("careful")
}
// ── compose ─────────────────────────────────────────────────────────
// `headers` covers info; `bodies` covers warn. The composed handler
// covers both — one merged frame, no nesting cost at the install site.
let headers = handler {
Log::info(m) => print("INFO {m}"),
}
let bodies = handler {
Log::warn(m) => print("WARN {m}"),
}
handle work() with handler.compose(headers, bodies)
// → INFO starting
// → WARN careful
// ── override ────────────────────────────────────────────────────────
// `prod` handles everything; in tests we want a quieter info path while
// keeping the original warn behaviour.
let prod = handler {
Log::info(m) => print("[prod] {m}"),
Log::warn(m) => print("[prod] !! {m}"),
}
let quiet_info = handler {
Log::info(_m) => { },
}
handle work() with handler.override(prod, quiet_info)
// → [prod] !! careful
// ── record ──────────────────────────────────────────────────────────
// Audit which ops fired without changing the underlying handler's
// behaviour. `log` is shared mutable state — every install of `audited`
// appends to it.
let (audited, calls) = handler.record(prod)
handle work() with audited
// → [prod] starting
// → [prod] !! careful
print("audited {calls.len()} calls; first was {calls[0].op}")
// → audited 2 calls; first was Log::info
// ── panic_unhandled ─────────────────────────────────────────────────
// Bottom of a compose chain: anything `top` doesn't cover surfaces as
// a loud error instead of silently bubbling. Here `headers` only covers
// info, so calling a work fn that only performs info is safe.
fn just_info() with Log {
perform Log::info("safe path")
}
handle just_info() with handler.compose(headers, handler.panic_unhandled())
// → INFO safe path
handler.record deserves special attention in test contexts: the handler's real logic
stays intact while the log captures exactly which operations were fired and with which
arguments — no mocking frameworks needed:
handler.record(stub) audits Db::query and Db::insert without changing the stub's behaviour.
// Feature: `handler.record(h)` — wrap any handler so each invocation
// is appended to a shared log table. The wrapped handler still
// delegates to the original arm (so behaviour is unchanged); the log
// is a side-channel for audit, debugging, or replay.
// When to use: capturing the exact sequence of operations a piece of
// code performs against a stub — for assertions in tests, for replay
// across runs, or for compliance audit logs.
use std::handler
use std::Array
effect Db {
fn query(sql: str) -> str
fn insert(table: str, value: str)
}
fn workflow() with Db -> str {
let _user = perform Db::query("SELECT * FROM users WHERE id = 42")
perform Db::insert("audit_trail", "user 42 viewed")
return perform Db::query("SELECT count(*) FROM users")
}
// A minimal stub handler — returns canned data without touching a real
// database. Good enough for tests; not interesting for audit on its
// own.
let stub = handler {
Db::query(_sql) => "row",
Db::insert(_t, _v) => { },
}
// Wrap it. `audited` behaves identically to `stub`, but every call is
// recorded into `log`.
let (audited, log) = handler.record(stub)
let result = handle workflow() with audited
print("result: {result}")
print("captured {log.len()} ops:")
for entry in log {
print(" - {entry.op}")
}
// expected:
// result: row
// captured 3 ops:
// - Db::query
// - Db::insert
// - Db::query
// Each log entry carries the op name and the args; useful in tests:
//
// assert_eq(log[0].op, "Db::query")
// assert_eq(log[0].args, ["SELECT * FROM users WHERE id = 42"])
//
// The wrapped handler is reusable — every fresh `handle ... with
// audited` appends to the same `log`. Inspect or reset between runs as
// appropriate.
See also