Skip to content

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.

09-handler-as-value.zolo
Playground
// 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; top covers its operations and delegates the rest to bottom.
  • override(base, patch)patch replaces only the clauses it declares; the remaining ones stay with base.
  • record(h) — returns (h2, log): h2 behaves exactly like h but records each invocation in log for auditing or test assertions.
  • panic_unhandled() — catch-all that panics if any uncovered operation arrives; useful at the base of a compose chain.

compose, override, record, and panic_unhandled in sequence over the same Log effect.

10-handler-combinators.zolo
Playground
// 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.

16-audit-with-record.zolo
Playground
// 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.
enespt-br