Multiple Effects
A function can declare more than one effect by separating them with + in the with
clause. The installed handler must cover the operations of all the effects the body
may invoke — but each effect remains an independent, swappable unit:
with IO + Logger; handler covers both in a single block.
// Feature: multi-effect signature — `with E1 + E2`
// Syntax: a function may declare more than one effect at once;
// the handler must cover every operation that the body performs.
// When to use: a workflow that mixes capabilities — e.g. read input
// (IO) and trace progress (Logger) — without coupling them into one effect.
effect IO {
fn read(path: str) -> str
}
effect Logger {
fn log(msg: str)
}
fn process() with IO + Logger -> str {
perform Logger::log("loading")
let data = perform IO::read("file.txt")
perform Logger::log("loaded")
return data
}
let result = handle process() with {
IO::read(p) => "<contents of {p}>",
Logger::log(msg) => print("LOG: {msg}"),
}
print(result)
// expected:
// LOG: loading
// LOG: loaded
// <contents of file.txt>
Handlers resolve lexically: an inner handle for the same effect shadows the
outer one until its scope ends. This lets you override parts of the behaviour in
sub-calls without rebuilding the entire handler:
Outer handler returns "OUTER(…)"; inner nested handler returns "NESTED" for the same operation.
// Feature: nested handlers — inner shadows outer for the same effect
// Syntax: handlers compose lexically; an inner `handle ... with` for
// the same operation takes over until that block exits.
// When to use: scoped overrides — e.g. inside a test block, swap the
// outer logger for a recording one without rebuilding everything.
effect IO {
fn read(path: str) -> str
}
fn read_thrice() with IO -> str {
let a = perform IO::read("a.toml")
let b = perform IO::read("b.toml")
let c = perform IO::read("c.toml")
return "{a}|{b}|{c}"
}
// Single outer handler — every read returns "OUTER(...)".
let outer = handle read_thrice() with {
IO::read(p) => "OUTER({p})",
}
print(outer)
// expected: OUTER(a.toml)|OUTER(b.toml)|OUTER(c.toml)
// An inner handler can be installed mid-flight via a sub-call —
// the same op resolves differently inside the nested `handle`.
fn read_one() with IO -> str {
return perform IO::read("inner.toml")
}
let mixed = handle read_thrice() with {
IO::read(p) => {
let nested = handle read_one() with {
IO::read(_q) => "NESTED",
}
return "{nested}/{p}"
},
}
print(mixed)
// expected: NESTED/a.toml|NESTED/b.toml|NESTED/c.toml
A single handle at the outermost layer captures performs from any depth in the
call stack. Intermediate functions only need to declare with Effect — they do not
need to know who will handle it:
top calls middle which calls leaf; one handler at the top captures all Log::info calls.
// Feature: transitive effects across the call stack — a single
// `handle` block catches `perform` calls from arbitrarily deep
// function calls. Intermediate functions only need to declare the
// effect (`with Log`); they don't need to know who eventually handles
// it.
// When to use: any layered architecture — UI calls service calls
// repository, all of which may log/trace/audit, and you want one
// install at the outermost layer to catch everything.
effect Log {
fn info(msg: str)
}
fn leaf(n: int) with Log -> int {
perform Log::info("leaf({n})")
return n * 2
}
fn middle(n: int) with Log -> int {
perform Log::info("middle({n})")
return leaf(n) + 1
}
fn top(n: int) with Log -> int {
perform Log::info("top({n})")
return middle(n)
}
// One `handle` at the outermost layer catches every Log::info from
// `top`, `middle`, AND `leaf`. None of those functions had to know
// about each other's handlers.
let result = handle top(3) with {
Log::info(msg) => print("[log] {msg}"),
}
print("result: {result}")
// expected:
// [log] top(3)
// [log] middle(3)
// [log] leaf(3)
// result: 7
// Each layer only declared `with Log` — the *protocol*. The
// interpretation lives entirely at the install site, free to swap
// without touching any of the producers.
Challenge
In 05-nested-handlers.zolo, install a third, even more inner handler that returns
"DEEP". Verify the resolution order.
See also