Skip to content

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 abort behavior 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.toml

The 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) // 7

The 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:

0

Both 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 Result for 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

DOCS / FEEDBACK

Did this page leave a question?

Tell us where the explanation lost you. Documentation is part of the language experience.

Global index

Find your way through Zolo

Try an idea

Start here

9 results

9 results

en