Functional Patterns
Three classic functional programming patterns emerge naturally from effects:
Reader — read-only context
The Reader pattern injects configuration, locale, or feature flags without passing
them explicitly through the entire signature chain. The handler closes over the
"environment" and responds to perform Config::get("key") accordingly. Swapping
environments means swapping the handler:
greet asks for greeting and lang via Config::get; en and pt handlers supply different worlds.
// Feature: Reader pattern — inject read-only context via a handler.
// Equivalent to dependency injection of an immutable config: the body
// asks via `perform Config::get("key")` and the handler decides what
// the answer is. Different `handle` sites install different worlds.
// When to use: any function that needs config, locale, feature flags,
// or env-derived constants — without threading them through every
// signature.
effect Config {
fn get(key: str) -> str
}
fn greet(user: str) with Config -> str {
let greeting = perform Config::get("greeting")
let lang = perform Config::get("lang")
return "{greeting}, {user}! (lang={lang})"
}
// Production-like environment.
let en = handler {
Config::get(key) => {
if key == "greeting" { return "Hello" }
if key == "lang" { return "en" }
return ""
},
}
let m1 = handle greet("Marco") with en
print(m1)
// expected: Hello, Marco! (lang=en)
// Same function, different "environment" — no rewiring needed at the
// call site. The body is unaware that the world changed.
let pt = handler {
Config::get(key) => {
if key == "greeting" { return "Olá" }
if key == "lang" { return "pt" }
return ""
},
}
let m2 = handle greet("Marco") with pt
print(m2)
// expected: Olá, Marco! (lang=pt)
State — scoped mutable state
The State pattern exposes get, set, and reset as effect operations. The handler
closes over a local mutable variable; each installation has its own counter. The body
does not need to know where the state lives:
Counter with get/incr/reset; handler closes over n; two sequential passes with reset.
// Feature: State pattern — `get`/`set`/`incr` ops backed by a handler
// that captures a mutable variable in its closure. The effectful body
// sees state as if it were an external resource; each install scopes
// its own counter.
// When to use: counters, accumulators, in-progress flags — short-lived
// mutable state you don't want to lift into every signature.
effect Counter {
fn get() -> int
fn incr()
fn reset()
}
fn tally(items: [str]) with Counter -> int {
for _ in items {
perform Counter::incr()
}
return perform Counter::get()
}
// The handler captures `n` in its closure. All three ops see the same
// variable across every perform within the handle scope.
let n = 0
let h = handler {
Counter::get() => n,
Counter::incr() => { n = n + 1 },
Counter::reset() => { n = 0 },
}
let total = handle tally(["a", "b", "c", "d"]) with h
print("after run: {total}")
// expected: after run: 4
// Outside the handle, `n` still holds the last value — useful for
// inspection (testing how many times an op fired) but risky to rely on
// for sequential reuse without a `reset` first.
print("captured: {n}")
// expected: captured: 4
// Reset for a second pass — same `h`, same closure, fresh logical
// counter.
fn reset_counter() with Counter { perform Counter::reset() }
let _ = handle reset_counter() with h
let again = handle tally(["x", "y"]) with h
print("second run: {again}")
// expected: second run: 2
Writer — producer decoupled from consumer
The Writer pattern lets the body emit messages without knowing where they go. The handler decides the destination — an in-memory list, stdout with a prefix, a file. Changing the destination requires no change in the producer:
pipeline() emits three steps; to_buffer accumulates in a list; to_stdout prints directly.
// Feature: Writer pattern — every `perform` appends to a collector
// the handler owns. Equivalent to streaming output: the body produces
// values, the handler decides where they go. The body never sees the
// collector — perfect separation of producer and consumer.
// When to use: log accumulation, event buffering, tracing, building a
// report — anywhere the producer should be unaware of the sink.
effect Out {
fn emit(msg: str)
}
fn pipeline() with Out {
perform Out::emit("step 1: parsing")
perform Out::emit("step 2: validating")
perform Out::emit("step 3: writing")
}
// First sink: a list. The handler captures it; the body fills it.
let buffer = []
let to_buffer = handler {
Out::emit(msg) => { buffer.push(msg) },
}
handle pipeline() with to_buffer
print("captured {buffer.len()} messages:")
for line in buffer {
print(" - {line}")
}
// expected:
// captured 3 messages:
// - step 1: parsing
// - step 2: validating
// - step 3: writing
// Same function body, different sink: send everything to stdout
// prefixed. No change in `pipeline()` — only the handler switches.
let to_stdout = handler {
Out::emit(msg) => print(">> {msg}"),
}
handle pipeline() with to_stdout
// expected:
// >> step 1: parsing
// >> step 2: validating
// >> step 3: writing
Real-world case: DB + Fs + Log + Metric
With multiple effects and handler-as-value, the same domain code runs in production
(handlers that call real services) and in tests (in-memory handlers that capture
writes, logs, and metrics). Each test reconstructs only the handle … with —
the process_orders function stays untouched:
Real workflow with Db + Fs + Log + Metric; five @test tests cover the happy, error, and mixed paths.
// Feature: real-world effects — Database + FileSystem + Logger + Metric
// Syntax: combine multiple effects in one signature; install one stack
// of handlers for production, swap them with in-memory mocks in `@test`.
// When to use: any function that touches IO or state — the same body
// runs in prod, in tests, in `--dry-run`, or in replay, by changing
// only the `handle ... with { ... }` block at the call site.
// ─────────────────── Effects (the contracts) ────────────────────────
// Each `effect` block declares operation signatures only — no bodies.
// A function's `with E1 + E2 + ...` clause is the inventory of
// capabilities it needs from whoever calls it.
effect Db {
fn query(sql: str) -> [str]
fn insert(table: str, row: str) -> int
}
effect Fs {
fn read(path: str) -> str
fn write(path: str, data: str)
fn exists(path: str) -> bool
}
effect Log {
fn info(msg: str)
fn error(msg: str)
}
effect Metric {
fn count(name: str)
}
// ─────────────────── Domain logic (knows nothing about IO) ──────────
// Reads pending order IDs from a file, looks each one up in the DB,
// "charges" them (insert into transactions), writes a receipt file,
// and tracks every step through Log/Metric. Returns how many orders
// were processed. There is no `if test_mode`, no injected struct, no
// global — every side effect is a `perform`, and the handler at the
// boundary decides what it actually does.
fn process_orders(path: str) with Fs + Db + Log + Metric -> int {
if !perform Fs::exists(path) {
perform Log::error("orders file missing: {path}")
perform Metric::count("orders.skipped.no_file")
return 0
}
let raw = perform Fs::read(path)
let ids = raw.split(",")
var processed = 0
for id in ids {
let sql = "SELECT name FROM users WHERE id = {id}"
let users = perform Db::query(sql)
if users.len() == 0 {
perform Log::error("unknown user id={id}")
perform Metric::count("orders.skipped.unknown_user")
continue
}
let name = users[0]
let row = "user={name},amount=10"
let txn = perform Db::insert("transactions", row)
let receipt_path = "receipts/{id}.txt"
let receipt_data = "txn={txn} user={name}"
perform Fs::write(receipt_path, receipt_data)
perform Log::info("charged {name} (txn {txn})")
perform Metric::count("orders.processed")
processed = processed + 1
}
perform Log::info("done: {processed} orders")
return processed
}
// ─────────────────── Production-shaped run ──────────────────────────
// In a real app these handlers would call sqlite, fs, journald,
// prometheus. Here they are printable stubs so `zolo run` shows the
// workflow end-to-end without external deps.
let n = handle process_orders("orders.csv") with {
Fs::exists(p) => p == "orders.csv",
Fs::read(_) => "1,2,99,3", // 99 is unknown
Fs::write(p, _) => print("[fs ] wrote {p}"),
Db::query(sql) => {
if sql.contains("id = 1") { return ["Alice"] }
if sql.contains("id = 2") { return ["Bob"] }
if sql.contains("id = 3") { return ["Carol"] }
return []
},
Db::insert(_, _) => 1001,
Log::info(m) => print("[INFO] {m}"),
Log::error(m) => print("[ERR ] {m}"),
Metric::count(_) => nil,
}
print("processed = {n}")
// expected:
// [fs ] wrote receipts/1.txt
// [INFO] charged Alice (txn 1001)
// [fs ] wrote receipts/2.txt
// [INFO] charged Bob (txn 1001)
// [ERR ] unknown user id=99
// [fs ] wrote receipts/3.txt
// [INFO] charged Carol (txn 1001)
// [INFO] done: 3 orders
// processed = 3
// ─────────────────── Tests — `zolo test` runs these ─────────────────
// Each test reinterprets the same `process_orders` against an
// in-memory world. Handler closures capture writes / logs / counters
// into local state; assertions read that state back. No mocking
// framework, no temp files, no globals — the whole "world" is the
// `with { ... }` block.
@test
fn test_happy_path_processes_all_users() {
let logs = []
let writes = #{}
let metrics = #{}
let n = handle process_orders("orders.csv") with {
Fs::exists(_) => true,
Fs::read(_) => "1,2",
Fs::write(p, d) => { writes[p] = d },
Db::query(sql) => {
if sql.contains("id = 1") { return ["Alice"] }
return ["Bob"]
},
Db::insert(_, _) => 42,
Log::info(m) => logs.push(m),
Log::error(m) => logs.push("ERR " + m),
Metric::count(k) => { metrics[k] = (metrics[k] ?? 0) + 1 },
}
assert_eq(n, 2)
assert_eq(writes["receipts/1.txt"], "txn=42 user=Alice")
assert_eq(writes["receipts/2.txt"], "txn=42 user=Bob")
assert_eq(metrics["orders.processed"], 2)
}
@test
fn test_unknown_user_is_skipped_and_logged() {
let errors = []
let metrics = #{}
let n = handle process_orders("orders.csv") with {
Fs::exists(_) => true,
Fs::read(_) => "7,8", // both missing
Fs::write(_, _) => nil,
Db::query(_) => [], // DB returns nothing
Db::insert(_, _) => 0,
Log::info(_) => nil,
Log::error(m) => errors.push(m),
Metric::count(k) => { metrics[k] = (metrics[k] ?? 0) + 1 },
}
assert_eq(n, 0)
assert_eq(errors.len(), 2)
assert_eq(errors[0], "unknown user id=7")
assert_eq(errors[1], "unknown user id=8")
assert_eq(metrics["orders.skipped.unknown_user"], 2)
}
@test
fn test_missing_file_short_circuits() {
// Handlers for ops that should NOT fire `panic` — proves the early
// return actually skips the rest of the workflow.
let errors = []
let metrics = #{}
let n = handle process_orders("ghost.csv") with {
Fs::exists(_) => false, // file is missing
Fs::read(_) => { panic("Fs::read should not be called"); "" },
Fs::write(_, _) => panic("Fs::write should not be called"),
Db::query(_) => { panic("Db::query should not be called"); [] },
Db::insert(_, _) => 0,
Log::info(_) => nil,
Log::error(m) => errors.push(m),
Metric::count(k) => { metrics[k] = (metrics[k] ?? 0) + 1 },
}
assert_eq(n, 0)
assert_eq(errors.len(), 1)
assert_eq(errors[0], "orders file missing: ghost.csv")
assert_eq(metrics["orders.skipped.no_file"], 1)
}
@test
fn test_logs_capture_full_trace() {
// The recorder pattern: a single `info` handler builds the trace
// we want to assert on. No spying, no proxy objects.
let logs = []
handle process_orders("o.csv") with {
Fs::exists(_) => true,
Fs::read(_) => "1",
Fs::write(_, _) => nil,
Db::query(_) => ["Alice"],
Db::insert(_, _) => 7,
Log::info(m) => logs.push(m),
Log::error(m) => logs.push("ERR " + m),
Metric::count(_) => nil,
}
assert_eq(logs.len(), 2)
assert_eq(logs[0], "charged Alice (txn 7)")
assert_eq(logs[1], "done: 1 orders")
}
@test
fn test_mixed_results_partial_processing() {
// 4 IDs, 2 valid, 2 missing — counts must reflect both buckets.
let metrics = #{}
let n = handle process_orders("orders.csv") with {
Fs::exists(_) => true,
Fs::read(_) => "1,99,2,77",
Fs::write(_, _) => nil,
Db::query(sql) => {
if sql.contains("id = 1") { return ["Alice"] }
if sql.contains("id = 2") { return ["Bob"] }
return []
},
Db::insert(_, _) => 1,
Log::info(_) => nil,
Log::error(_) => nil,
Metric::count(k) => { metrics[k] = (metrics[k] ?? 0) + 1 },
}
assert_eq(n, 2)
assert_eq(metrics["orders.processed"], 2)
assert_eq(metrics["orders.skipped.unknown_user"], 2)
}
See also