Guide 31 of 53
Algebraic effects
Declarative dependency injection: declare what your function needs, decide on the edges. Beats DI containers, mocks, monads and async/await viral.
On this page
Status: available on the VM and on the native Cranelift and LLVM backends for the core one-shot model. Read Backend status before relying on terminal
abortbehavior or wasm-aot.
Algebraic effects let a function name the capabilities it needs without choosing their implementation. The caller installs a handler at the boundary, so the same function can use a production service, an in-memory test double, or a recorder without receiving a dependency container.
A minimal effect¶
effect IO {
fn read(path: str) -> str
}
fn read_config() with IO -> str {
return perform IO::read("config.toml")
}
let config = handle read_config() with {
IO::read(path) => "default from {path}",
}
print(config)Output:
default from config.tomlThe four pieces are:
| Purpose | Syntax |
|---|---|
| Declare operations | effect IO { fn read(path: str) -> str } |
| Declare a function's effects | fn load() with IO -> str |
| Request an operation | perform IO::read("config.toml") |
| Interpret operations | handle load() with { IO::read(path) => value } |
The with clause comes after the parameter list and before the return arrow.
Combine effects with +, as in fn run() with Log + Clock -> int.
An inline handler arm receives the operation's arguments. Its resulting value
becomes the value of perform. A handler { ... } literal creates a reusable,
first-class handler value that can be installed with handle expression with handler_value.
Installing a handler around a block¶
Put the handler before a lexical block with with handler_value { ... }:
let memory = handler {
IO::read(path) => "default from {path}",
}
let config = with memory {
read_config()
}This uses the same first-class handler and answer type as
handle { read_config() } with memory. The handler expression runs once
before entering the body. A nested boundary temporarily replaces the matching
operations and restores the outer handler when it finishes. The block keeps
its caller's lexical control flow, including return, loop exits and Result
propagation; the established handler runtime performs restoration and cleanup.
Existing defer admission rules still apply: cleanup cannot use return or
loop exits to leave its caller, and Result propagation needs a local
attempt boundary. A finally block keeps its established lexical exit
behavior.
Parentheses allow complex handler expressions, as in
with (handler.compose(files, log)) { work() }. An unparenthesized block after
the handler is always the handled body. A function's with IO capability
clause and an immutable record update retain their existing forms.
The value needs a real handler<Effects, Answer> type. An unresolved Any
or Unknown cannot establish coverage and receives TE814. Actual handler
combinators preserve their declared effect rows and answer types; a convenient
name or a target annotation alone does not prove a handler value.
The prefix form keeps the backend limits of the existing effect transport. It does not add multi-shot continuations or change the documented native terminal-abort limitation. Backend execution checks are part of the final validation batch for this syntax sequence.
Overriding selected operations¶
Use with { Effect::operation(...) => ... } on a handler value to replace selected operations:
effect Clock { fn now() -> int }
let world = handler { Clock::now() => 1 }
let test_world = world with { Clock::now() => 7 }
let now = with test_world { perform Clock::now() }
print(now) // 7The base runs once. Its other operations, effect coverage and installation answer type are retained. Each replacement receives the operation's declared parameter types and returns that operation's result type. The replacement's parameters belong to its own arm.
Creating the value does not install it. with test_world { ... } installs it around the following block. The compiler requires a checked, complete handler origin; an erased value or a handler type annotation alone does not establish a safe override. An override preserves the existing restrictions on abort and continuation ownership.
resume and abort¶
Returning the arm's final expression resumes the suspended perform
implicitly. Use resume(value) when the arm needs to finish explicitly after
logging or branching.
abort(value) is terminal: it discards the pending continuation and makes
value the result of the enclosing handle.
effect Guard {
fn check(value: int) -> int
}
fn double_checked(value: int) with Guard -> int {
let checked = perform Guard::check(value)
return checked * 2
}
let result = handle double_checked(-4) with {
Guard::check(value) => {
if value < 0 {
abort(0)
}
resume(value)
},
}
print(result)Output:
0Both forms are one-shot today. Declaring a multi fn is accepted and checked,
but invoking resume repeatedly to explore multiple continuations is not a
portable runtime feature yet.
Static diagnostics¶
Run zolo check when effect safety matters. The public diagnostic family is
TE800 through TE814:
| Codes | What they report |
|---|---|
TE800, TE809 |
a performed effect is missing from the enclosing boundary |
TE801, TE802 |
an unknown effect or an uncovered effectful call |
TE803, TE807 |
incomplete inline or first-class handler coverage |
TE804–TE806 |
unknown operation, wrong arm arity, or duplicate arm |
TE808 |
operation argument type mismatch |
TE810 |
handler coverage that the body does not use (warning) |
TE811 |
a generic name used as both a type variable and an effect-row variable |
TE812 |
a multi-shot operation on the native backend |
TE813 |
an ambiguous generic-effect type argument |
TE814 |
a value without a proven handler type at either handler boundary |
TE809 is the boundary summary used for public functions and implementation
methods; TE800 remains the per-site form for top-level and other sites that
have no single function signature to annotate.
One CLI gap is intentional to document: the native zolo build path does not
currently run the TE812 multi-shot check even though the compiler API and its
tests do. Do not treat a successful native build of a multi fn program as
proof of multi-shot support.
Backend status¶
| Backend | Current status |
|---|---|
VM (zolo run) |
Reference behavior for the one-shot model and the full feature examples |
| Native / Cranelift | Core declaration, perform, inline and first-class handlers, handler combinators, resume, and abort are in the VM/native differential corpus |
| LLVM | Uses the shared native lowering and an unwind-capable transport; core one-shot effects are implemented |
| wasm-aot | No effects parity corpus is maintained; treat effects as unsupported until a wasm-aot e2e case exists |
Cranelift has one documented semantic exception. It emits no unwind metadata,
so terminal abort uses a pending-flag transport. Statements between the
aborting perform and the end of the handled body may still run with a
placeholder value before the handler's abort value is delivered. The
abort_side_effects.zolo differential fixture is skipped on native for this
reason. LLVM can unwind and matches the VM for that case.
Nested resume or abort inside another lambda in a handler arm has the same
Cranelift transport limitation. Keep terminal control directly in the arm when
portable side-effect ordering matters.
Practical guidance¶
- Use effects at capability boundaries: filesystem, clock, network, database, randomness, logging, and process execution.
- Keep pure transformations as ordinary functions.
- Install handlers near the application entry point or test boundary.
- Prefer
Resultfor a local operation that simply returns an expected error; effects are most useful when a capability crosses several call layers. - Use a first-class handler when the same interpretation is installed more
than once or composed with
std::handler.
Learn more¶
- Effects by example starts with runnable programs and continues through handlers, rows, and generic effects.
- Effect diagnostic reference
documents each
TE800–TE814code. - VM/native effects corpus is the source of truth for differential backend claims and skip reasons.
Related examples and reference¶
DOCS / FEEDBACK
Did this page leave a question?
Tell us where the explanation lost you. Documentation is part of the language experience.