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.
// 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.
// 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.
// 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.
// 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.
// 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]