Skip to content

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.

04-multi-effect.zolo
Playground
// 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.

05-nested-handlers.zolo
Playground
// 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.

15-transitive-effects.zolo
Playground
// 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.

enespt-br