Skip to content
On this page

Algebraic effects

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.

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
TE804TE806 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 non-handler value after handle ... with

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

Search Zolo

9 results

en