Skip to content

Advanced Types

Multi-shot operations

The multi keyword marks an operation whose handler may invoke resume(v) more than once, exploring multiple continuations — non-determinism, search, generators. In Milestone A the compiler already validates the types; runtime semantics arrive in Milestone B. Declaring multi now guarantees the contract will be correct when the runtime evolves:

multi fn pick(opts: [int]) declared; deterministic one-shot handler works today; Milestone B preview commented out.

11-multi-shot.zolo
Playground
// Feature: `multi fn op(...)` — declares a multi-shot operation. The

// `multi` keyword marks ops whose handler arms may invoke `resume(v)`

// more than once, exploring multiple continuations of the same

// computation (non-determinism, search, generators).

//

// Marco A: the keyword is recognised by the parser and threaded through

// the type system. The VM backend treats `multi` ops identically to

// one-shot at runtime today — a handler that calls `resume` more than

// once is undefined behaviour. The native backend rejects any `perform`

// of a `multi` op (compile error TE812) because stack-copying

// continuations are not yet implemented.

//

// When to use: declare `multi` whenever the operation could *logically*

// support multi-shot — so the contract is correct ahead of the Marco B

// runtime work. Marco B will turn the annotation into real semantics

// without any change to the source below.


effect Choice {
  multi fn pick(opts: [int]) -> int
}

fn coin_flip() with Choice -> str {
  let n = perform Choice::pick([0, 1])
  if n == 0 { return "heads" }
  return "tails"
}

// One-shot handler: today, arms call `resume(v)` at most once. The

// `handle` block evaluates to whatever the body produces after resume.

let trial = handle coin_flip() with {
  Choice::pick(opts) => {
    // Deterministic stand-in: always pick the first option.

    resume(opts[0])
  },
}
print("trial: {trial}")
// expected: trial: heads


// Type-check enforcement (Marco A): args to `perform` are checked

// against the operation's declared parameter types. The following line,

// if uncommented, would fail with TE808 — `[true, false]` is `[bool]`,

// not `[int]`:

//

//   let bad = perform Choice::pick([true, false])

//

// Likewise, handler arm bodies are checked against the op's return

// type. A body that produces `str` for a `pick` arm would also fail

// (`pick` returns `int`).


// Marco B preview — the same source will gain real multi-shot semantics

// once the VM grows stack-copying continuations. The handler below is

// the canonical "explore every branch" shape; it does not run today,

// but the declaration above is already correct for it:

//

//   handle coin_flip() with {

//     Choice::pick(opts) => {

//       let mut results = []

//       for o in opts { results.push(resume(o)) }

//       results

//     },

//   }

//   // Marco B output: ["heads", "tails"]

Generic effects

An effect can be parameterised by a type: effect State<T> works equally for State<int> and State<str>. The compiler resolves T at the installation point via unification with the handler clauses — and refuses to mix handlers of different types:

State<int> for counting items; State<str> for accumulating names; same declaration, two distinct handlers.

17-generic-state.zolo
Playground
// Feature: generic effects — `effect State<T> { ... }` declares a

// type-parameterised effect. The same source supports `State<int>`,

// `State<str>`, or any other monomorphisation; the compiler picks `T`

// at the install site via unification with arm bodies or with the

// surrounding context (`with State<int>` on the fn signature).

// Syntax: `effect Name<T1, T2, ...> { fn op(p: T1) -> T2 }` declares;

// `with State<int>` pins T at perform sites.

// When to use: any state-shaped capability you want to apply to more

// than one concrete value type without duplicating the effect.


effect State<T> {
  fn get() -> T
  fn set(v: T)
}

// `with State<int>` pins T = int for the body's perform sites.

fn count(items: [str]) with State<int> -> int {
  perform State::set(0)
  for _ in items {
    perform State::set(perform State::get() + 1)
  }
  return perform State::get()
}

// The handler closes over a mutable int. Both arms type-check against

// State<int>: `get`'s body returns `n: int` and `set`'s param `v: int`

// unifies T = int.

let n = 0
let int_handler = handler {
  State::get() => n,
  State::set(v) => { n = v },
}

let total = handle count(["a", "b", "c", "d", "e"]) with int_handler
print("counted: {total}")
// expected: counted: 5


// Same effect, different T. A second handler for `State<str>`.

fn collect_names() with State<str> -> str {
  perform State::set("")
  perform State::set("alice")
  perform State::set("bob")
  return perform State::get()
}

let s = ""
let str_handler = handler {
  State::get() => s,
  State::set(v) => { s = v },
}

let last = handle collect_names() with str_handler
print("last set: {last}")
// expected: last set: bob


// The two handlers are different `handler<State<int>, int>` and

// `handler<State<str>, str>`. The compiler refuses to mix them — a

// `handle count(...) with str_handler` would be a type error at the

// install site.

Row polymorphism

Combinators such as retry need to wrap a computation that already carries its own effects. The row variable e in the signature represents "whatever extra effects the caller brings" — they pass through the combinator without being named:

fn retry<T, e>(n: int, f: fn() with {Fail | e} -> T) with {Fail | e} -> T

The caller declares only the effects it handles itself; the combinator takes care of Fail:

attempt brings Fail + Log; caller declares only Log; effect.retry consumes Fail and passes Log through.

18-row-polymorphism.zolo
Playground
// Feature: row polymorphism — write a combinator once, use it under

// any caller's effect set. The row variable `e` represents "any extra

// effects the user brings"; the combinator passes them through

// unchanged.

// Syntax: `fn name<T, e>(...) with {Fail | e}` declares `e` in the

// generic param list and uses it as a row tail. At call sites, `e`

// binds to whatever extra effects the argument's signature requires.

// When to use: any helper that wraps an effectful computation —

// retry, timeout, parallel, map_with_effect — without locking the

// inner computation's effect set.


use std::effect

// User declares Fail + Log. `Fail` matches the stdlib's declaration

// by name; effects are nominal across the program.

effect Fail { fn fail(reason: str) -> int }
effect Log { fn info(msg: str) }

// `attempt` brings BOTH Fail (which retry expects) and Log (which the

// caller must handle via the row var `e`).

fn attempt() with Fail + Log -> int {
  perform Log::info("trying...")
  return 42
}

// `caller` declares only Log — Fail is consumed by retry's signature.

// The row variable in retry's signature carries Log through unchanged,

// so the substitution at the retry call site yields `with Log`.

fn caller() with Log -> int {
  return effect.retry(3, attempt)
}

let answer = handle caller() with {
  Log::info(m) => print("[log] {m}"),
}
print("got: {answer}")
// expected:

//   [log] trying...

//   got: 42

Effect aliases

type FullStack = Db + Fs + Log abbreviates repeated effect sets. The alias expands transparently at every with point — the type system treats with FullStack as with Db + Fs + Log everywhere:

type FullStack = Db + Fs + Log; signature and handler use the alias without manually expanding it.

19-effect-aliases.zolo
Playground
// Feature: effect aliases — `type Name = E1 + E2 + ...` lets you
// abbreviate common effect bundles. Aliases expand transparently at
// every `with` site; the type system treats `with FullStack` as
// `with Db + Fs + Log` everywhere.

effect Db {
  fn query(sql: str) -> str
}

effect Fs {
  fn read(p: str) -> str
}

effect Log {
  fn info(m: str)
}

// One declaration captures the common dependency set for the whole
// app's data layer.
type FullStack = Db + Fs + Log

fn workflow() with FullStack -> str {
  perform Log::info("starting")
  let data = perform Fs::read("config.toml")
  let row = perform Db::query("SELECT * FROM users")
  return "data: {data}, row: {row}"
}

let result = handle workflow() with {
  Db::query(_sql) => "user:42",
  Fs::read(p) => "[stub: {p}]",
  Log::info(m) => print("[log] {m}"),
}

print(result)
// expected:
//   [log] starting
//   data: [stub: config.toml], row: user:42

std::effect combinator suite

std::effect provides retry, parallel, and map_with_effect ready to use. All are row-polymorphic — they work regardless of the extra effects that the inner functions declare:

effect.retry with a fragile function; effect.parallel with two tasks; effect.map_with_effect over a list.

20-combinator-suite.zolo
Playground
// Feature: row-polymorphic combinator suite — `retry`, `parallel`,

// `map_with_effect` from `std::effect`. Each carries the caller's

// effects through the row variable, so the same combinator works

// regardless of what extra effects the inner fns perform.


use std::effect

effect Fail {
  fn fail(reason: str) -> int
}

effect Log {
  fn info(m: str)
}

// ── retry ──────────────────────────────────────────────────────────

var attempts = 0

fn flaky() with Fail + Log -> int {
  attempts = attempts + 1
  perform Log::info("attempt {attempts}")
  if attempts < 3 {
    perform Fail::fail("not yet")
  }

  return 42
}

fn run_retry() with Fail + Log -> int {
  return effect.retry(5, flaky)
}

let r = handle run_retry() with {
  Fail::fail(_r) => -1,
  Log::info(m) => print("[log] {m}"),
}

print("retry result: {r}")

// expected:

//   [log] attempt 1

//   [log] attempt 2

//   [log] attempt 3

//   retry result: 42


// ── parallel ───────────────────────────────────────────────────────

fn task(n: int) with Log -> int {
  perform Log::info("task({n})")
  return n * 10
}

fn task1() with Log -> int { return task(1) }

fn task2() with Log -> int { return task(2) }

fn run_parallel() with Log -> [int] {
  return effect.parallel([task1, task2])
}

let pa = handle run_parallel() with {
  Log::info(m) => print("[par] {m}"),
}
print("parallel: {pa}")

// expected:

//   [par] task(1)

//   [par] task(2)

//   parallel: [10, 20]


// ── map_with_effect ────────────────────────────────────────────────

fn run_map() with Log -> [int] {
  return effect.map_with_effect([10, 20, 30], task)
}

let ma = handle run_map() with {
  Log::info(m) => print("[map] {m}"),
}
print("map: {ma}")
// expected:

//   [map] task(10)

//   [map] task(20)

//   [map] task(30)

//   map: [100, 200, 300]

enespt-br