Handlers
How handlers give effects their meaning, how to install them with `with`, handler state, and how tests swap handlers.
Pre-alpha
The source-built compiler runs the core language and much of M3 on JavaScript: contracts, typed effects and failures, std handlers, permissions, LSP and MCP. M3 is still in progress; no release or stable compatibility contract exists. See the roadmap for implemented features and remaining gates.
An effect declares operations. A handler gives them meaning. Code names the effect; the caller picks the handler. The same function can run against SQLite in production and an in-memory table in a test.
Writing a handler
handler Eff { … } is an expression. It produces a value of type Handler<Eff, r>, where r is the row its clauses perform. Handler<Eff> is short for Handler<Eff, {}>.
effect Counter {
fn next() -> Int
fn reset() -> Unit
}
fn memory_counter(start: Int) -> Handler<Counter> {
handler Counter {
var n: Int = start
fn next() { n = n + 1; n }
fn reset() { n = start }
}
}- There is exactly one clause per operation (
V3011), with the same number of parameters. - Clause parameters are not annotated. Their types come from the
effectdeclaration. varmembers are handler state. They are visible only inside the clauses, initialized once per installation, and updated by assignment. The runtime runs clauses of one instance one at a time.
Installing handlers with with
with h1, h2 { body } runs body with the handlers installed. Handlers are installed innermost-last: h2 sits inside h1. The with expression's row is the body's row minus each handled effect, plus each handler's r.
fn label_all(items: List<String>) -> List<String> ! {Counter} {
List.map(items, fn(item) { "{Counter.next()}. {item}" })
}
test "labels count from the handler's start" {
let out = with memory_counter(0) { label_all(["a", "b"]) }
expect_eq(out, ["1. a", "2. b"])
}label_all requires Counter. The with satisfies it, so the test body has no Counter left in its row. Using with on a value that is not a handler is V3012.
Resumption without resume
A clause runs in place of the operation call. Its return value is the operation's result. This happens exactly once, in tail position, by construction. Handlers are tail-resumptive: there is no resume, no multi-shot resumption, and no first-class continuations. resume is a reserved keyword (V0007).
This keeps handlers cheap and predictable. A handler is a service the body calls, not a construct that can rewind or duplicate the body. Generators and backtracking would need non-tail control operations; design §16 lists them as a possible later edition, not v0.
Nesting and shadowing
For the same effect, the innermost handler wins. There is no masking or injection.
test "inner handler shadows outer" {
let out = with memory_counter(0) {
with memory_counter(100) { Counter.next() }
}
expect_eq(out, 101)
}Clauses that perform effects
A clause may perform other effects. They go to the handlers installed outside the with. Calling the same effect inside its own clause reaches the next outer handler, which is how wrappers work:
fn prefixed(prefix: String) -> Handler<Log, {Log}> {
handler Log {
fn event(level, message, fields) { Log.event(level, "{prefix}{message}", fields) }
}
}prefixed handles Log and performs Log on the outer handler, so its type is Handler<Log, {Log}>. A Db handler whose clauses call Http has type Handler<Db, {Http}>, and installing it adds Http to the surrounding row. A handler cannot create authority from nothing.
A clause may fail only with tags declared on its operation. Given this declaration:
error DbUnavailable(reason: String)
effect Db {
fn exists(table: Table, key: String) -> Bool
fn insert(table: Table, row: Json) -> Unit ! {Fail<DbUnavailable>}
}
fn memory_db() -> Handler<Db> {
handler Db {
var rows: Map<String, Json> = Map.empty()
fn exists(table, key) { Map.contains(rows, "{table}/{key}") }
fn insert(table, row) {
rows = Map.insert(rows, "{table}/{Json.get_string(row, "id")}", row)
}
}
}the insert clause may fail DbUnavailable { … }, and exists may not fail at all.
Failure is not handled with handlers
Fail<E> is the one compiler-known effect. You recover from it with try/catch, not with a handler. See Failure.
Handlers are values
Handlers are first-class. A function can return one, and a record can store one. A handler that captured a capability is a scoped value: it cannot outlive the with that produced the capability (V6002). When a with exits, by normal return, fail, defect, or cancellation, its handler state and captured resources are released.
Root handlers come from the host
main declares the std effects the program needs. The host supplies root handlers for exactly those, limited by [permissions] in sakura.toml. Only std effects may appear in main's row (V6010); user effects such as Counter must be handled inside the program. This program reads its arguments, so it declares Cli. From design §15.6 (abridged):
import std/cli.{Cli, CliError}
import std/fs.{Fs, FsError, Dir}
pub fn main() -> Unit ! {Console, Cli, Fs, Fail<CliError | FsError>} {
let args: Args = Cli.parse(Cli.args())
// Narrow the root Fs handler to the data directory before touching files.
with Fs.rooted(Dir.subdir(Fs.root(), "data")) {
let text = Fs.read_text(args.input).trust(@trust("word counting cannot be influenced by content"))
let top = List.slice(List.sort_by(Map.entries(word_counts(text)), fn(e) { 0 - e.1 }), 0, args.top)
for (word, n) in top { Console.print("{word}: {n}") }
}
}Effects are requirements; handlers are authority. See Capabilities and security.
Standard handlers
From design §10.2. Each constructor is a function of the effect's home module, so Console.host() is std/console.host.
| Effect | Handlers |
|---|---|
Console | Console.host(), Console.captured() |
Clock | Clock.host(), Clock.fixed(instant), Clock.virtual() |
Random | Random.host(), Random.seeded(seed) |
Env | Env.host(scope), Env.of(map), Env.only(keys) |
Log | Log.stderr(), Log.json(), Log.captured() |
Fs | Fs.host(dir), Fs.rooted(dir), Fs.memory(files) |
Http | Http.host(scope), Http.scoped(scope), Http.scripted(script), Http.recording(path) |
Serve | Serve.host(scope) |
Sql | Sql.sqlite(path), Sql.memory(), Sql.postgres(url) (v1) |
Cli | Cli.host(), Cli.of(args) |
Budget | Budget.limits(limits), Budget.unlimited() |
Llm | Llm.anthropic(key, model), Llm.scripted(responses), Llm.replay(path) |
Task | Task.host(), Task.deterministic(seed) |
Tests swap handlers
A test runs under deterministic default handlers: Clock.fixed(Instant.from_unix_millis(0)), Random.seeded(seed) (from sakura test --seed), Env.of(Map.empty()), Console.captured(), and Log.captured(). Captured output can be checked with expect_output(lines). Any other effect must be handled inside the test. Otherwise sakura check reports V3020 ("test uses Db; install a handler with with"). Typed failures are the exception: by design a test body may let Fail<E> escape, and the test then fails with the error's Show. The current compiler does not implement that yet and still reports V3020 for it.
test "deterministic under virtual time" {
with Http.scripted([(Any, Response.status(200, "x"))]), Clock.virtual(), Random.seeded(1), Task.deterministic(1) {
expect_eq(fetch_both(Url.from("https://a"), Url.from("https://b")), ("x", "x"))
}
}fetch_both is the example on the Concurrency page. Because time, randomness, scheduling, and the network are all handlers, a test controls every one of them.
Retry, timeout, budget
retry and timeout are ordinary std functions over thunks, not handlers. Budget is an effect, and Budget.limits(limits) is its handler, configured with a Limits { tokens, usd, calls } record. See Concurrency.
What the compiler checks
- A
handlerhas exactly one clause per operation, with matching parameter counts (V3011). withis applied only to handler values (V3012).- A
withremoves the effects it handles and adds each handler's row. The result is checked against the signature (V3001, reported at the clause when a clause performs an undeclared effect). - A clause fails only with tags declared on its operation.
- A test installs a handler for every effect outside the deterministic defaults (
V3020). - A handler that captured a capability does not escape its scope (
V6002). main's row contains only std effects (V6010).
Next
Contracts: refinement types, requires, and ensures.