Skip to content

Effects

The five effects the compiler infers, what ATOLL2031 and ATOLL2032 mean, and the rarely-needed `using [...]` row.

Updated View as Markdown

An effect is something a function may do beyond returning a value: suspend, fail, allocate, spawn, or cancel. The compiler tracks all five for you.

You almost never write an effect down. Effects are inferred from what a function calls, and ordinary Atoll code carries no effect annotations at all — there is not a single one in the compiler’s sample projects, its test fixtures, or its prelude. Read this page to understand the diagnostics you will see, not because you are expected to annotate anything.

fn checksum(data: []byte): int {
    mut sum := 0
    for b in data {
        sum = sum + b.to_int()
    }
    return sum
}

fn log_checksum(data: []byte): int {
    total := checksum(data)
    println("checksum ${total}")
    return total
}

checksum only computes. log_checksum calls println, which is a @suspend host op, so the compiler infers Suspend for it — and for everything that calls it. That inference is what drives the CPS transform in the backend; it is not a style rule you are being asked to follow.

The explicit using [...] row exists, and the rest of this page shows it, because it is how you read the diagnostics and how you would pin a contract on a published API boundary. Treat it as a specialist tool.

The five effects

There are exactly five. Any other name in a using clause is an error.

Effect A function carries it when it may…
Suspend pause and resume later — a @suspend host call, a task join, a blocking select, a backpressured send_await
Error leave through the error channel — error V, or ? on a Result
Alloc allocate in managed arena storage — build a List, Map, Set, or interpolated string
Spawn start a child task — spawn, or race (which spawns each arm)
Cancel request cancellation — Task.cancel(), or race (which cancels losers)

Each one, in isolation:

error DecodeError { Truncated }

fn may_suspend(): void using [Suspend] {
    println("waiting")
}

fn may_error(input: string): int ! DecodeError using [Error] {
    if input.is_empty() { error Truncated }
    return input.len()
}

fn may_allocate(n: int): []string using [Alloc] {
    mut out: []string = []
    mut i := 0
    for i < n {
        out.add("row ${i}")
        i = i + 1
    }
    return out
}

fn may_spawn(n: int): int using [Spawn, Suspend] {
    child := spawn { n * 2 }
    return child.await()
}

fn may_cancel(n: int): void using [Spawn, Suspend, Cancel] {
    child := spawn { n * 2 }
    child.cancel()
}

spawn always brings Suspend along with Spawn, because the join that follows it can pause. race brings all three of Spawn, Suspend, and Cancel.

An effect is a conservative may fact. Suspend does not mean every call will yield — awaiting an already-completed task takes a synchronous fast path. It means the caller must be compiled to permit a yield on some reachable path.

Rows are sets. Repeating a name adds nothing, and the row records neither how many times an operation happens nor in what order.

Inference

Most functions should not declare a row at all. Omit the clause and the compiler computes one, unioning the rows of everything the body calls until the whole call graph reaches a fixed point.

fn fetch(id: int): int {
    println("fetching ${id}")
    return id
}

fn fetch_pair(a: int, b: int): int {
    return fetch(a) + fetch(b)
}

fn report(a: int, b: int): int {
    return fetch_pair(a, b)
}

None of these declare anything, and all three end up carrying Suspend: println seeds it, fetch inherits it, fetch_pair inherits it from fetch, and report from fetch_pair. Recursion converges the same way — to the union of everything reachable in the recursive component.

ATOLL2031 — the inheritance hint

ATOLL2031 is a hint. It fires when a function’s row picks up an effect from a called function rather than from an operation in its own body, and it fires whether or not you declared that effect. It is a trace, not a complaint.

fn tick(): int {
    println("tick")
    return 1
}

fn twice(): int using [Suspend] {
    return tick() + tick()
}

Checking that unit prints:

hint[ATOLL2031]: fn `twice` inherits effect `suspend` from a transitively-called
callee

The declaration is correct and complete; the hint simply tells you where Suspend came from, so you can follow the chain when a row surprises you. Diagnostics render normalized lowercase names (suspend, alloc); source uses the PascalCase spellings.

There is nothing to fix. Hints never fail a build.

ATOLL2032 — the row-violation warning

ATOLL2032 is a warning. It fires when a function inherits an effect through a call edge that its declared row does not list. A function with no using clause is treated as declaring nothing, so it triggers the warning too as soon as it calls something effectful.

fn poll_backend(): int using [Suspend] {
    println("polling")
    return 1
}

fn refresh(): int using [] {
    return poll_backend()
}

That unit still compiles, but reports:

warning[ATOLL2032]: fn `refresh` inherits effect `suspend` from a
transitively-called callee but its declared `using [..]` row does not list
`suspend`

Because a function with no using clause declares nothing, this warning fires on almost every effectful function in an ordinary program. That is the normal state of Atoll code, not a defect in it.

The responses, in order of how often they are the right one:

  1. Ignore it. This is the usual answer. The program is correct, the compiler has inferred the effect it needs, and nothing downstream is weaker for it. The warning is telling you the inferred row is wider than the declared one — and you never declared one.
  2. Stop calling the effectful thing — if you wanted this function to stay pure, move the work to a caller and take the result as a parameter. Use the warning as the signal that purity slipped, not as a prompt to annotate.
  3. Widen the row — only when you have deliberately written a using clause because this function is a contract boundary you want checked.
  4. Make the boundary polymorphic — if the effect arrives through a callback, thread it with a row variable (below) rather than hard-coding it.

Widening is the fix here:

fn poll_backend(): int using [Suspend] {
    println("polling")
    return 1
}

fn refresh(): int using [Suspend] {
    return poll_backend()
}

Response 2 is worth seeing too, because it is the one that keeps a row narrow:

fn poll_backend(): int using [Suspend] {
    println("polling")
    return 1
}

fn interpret(sample: int): string using [] {
    return if sample > 0 { "up" } else { "flat" }
}

fn refresh(): string using [Suspend] {
    return interpret(poll_backend())
}

interpret does the decision-making and stays provably effect-free; only the thin outer function carries Suspend.

Do not reflexively add all five effects to silence the warning. using [] is worth writing precisely because it turns “this stayed pure” into a checked statement.

Note the boundary of the check: ATOLL2032 is about inherited effects. An operation written directly in the body of a using [] function is not currently reported, so the row is an assertion about your call graph, not a sandbox.

Unknown effect names are errors

Unlike the two diagnostics above, a bad name in a using clause fails the build.

fn f(): int using [Blocking] {
    return 1
}

That is ATOLL2021: unknown effect name 'Blocking' in using clause. The lowercase spellings that appear in diagnostics are not accepted in source either:

fn f(): int using [suspend] {
    return 1
}

Row variables

A higher-order function should not have to claim its callback’s effects. Bind a generic parameter and use it as a row:

fn apply[T, R, E](value: T, op: fn(T) using [E] -> R): R using [E] {
    return op(value)
}

fn shout(s: string): string {
    println(s)
    return s
}

fn pure_twice(n: int): int {
    return n * 2
}

fn use_pure(): int using [] {
    return apply(21, pure_twice)
}

fn use_suspending(): string using [Suspend] {
    return apply("hi", shout)
}

One apply, two specializations. With pure_twice the row variable resolves to the empty row, so use_pure stays using []. With shout it resolves to [Suspend], and the caller must permit it.

A row variable may sit behind a fixed prefix. using [Suspend, E] means “always suspends, plus whatever the callback contributes”:

fn instrumented[T, E](op: fn() using [E] -> T): T using [Suspend, E] {
    println("begin")
    result := op()
    println("end")
    return result
}

fn make_answer(): int {
    return 42
}

fn run(): int using [Suspend] {
    return instrumented(make_answer)
}

The identifier must be a generic parameter of the enclosing declaration. A bare capital letter that binds nothing is just an unknown effect name:

fn f(): int using [E] {
    return 1
}

Effects in function types

Effect rows are part of a function type, so they participate in assignment compatibility.

type PureTransform = fn(string) using [] -> string
type WaitingTransform = fn(string) using [Suspend] -> string

fn upper(s: string): string using [] {
    return s.to_upper_ascii()
}

fn announce(s: string): string using [Suspend] {
    println(s)
    return s
}

fn run(): string using [Suspend] {
    quiet: PureTransform = upper
    loud: WaitingTransform = announce
    return quiet("a") + loud("b")
}

The using clauses on upper and announce are load-bearing here. Matching a function type that names a non-empty row requires a declared row on the function value — an inferred row does not unify with it:

type WaitingTransform = fn(string) using [Suspend] -> string

fn announce(s: string): string {
    println(s)
    return s
}

fn run(): string using [Suspend] {
    loud: WaitingTransform = announce
    return loud("b")
}

announce inherits Suspend, but because it declares nothing the assignment is rejected with ATOLL2002. Add using [Suspend] to announce and it compiles. This is one of the places where declaring a row is not optional.

Effects are not error types

Error and the T ! E signature answer different questions. The signature says which error values can escape; the effect says that an error exit exists.

error StoreError { Missing, Conflict }

fn read(key: string): string ! StoreError using [Error] {
    if key.is_empty() { error Missing }
    return "value"
}

fn read_or_default(key: string): string using [Error] {
    return read(key).unwrap_or("fallback")
}

read_or_default discharges the Result with unwrap_or, so no error value can escape it — yet it still declares Error, because the effect travels along the call edge to read regardless of how the result is consumed. Dropping the declaration there yields an ATOLL2032 warning, not a narrower contract.

Likewise, Suspend does not say why a function waits, and Alloc does not expose an allocator. Effects are static contracts used for checking, ABI selection, and lowering. They are not runtime handlers, they do not authorize unsafe memory access, and they are not a security boundary.

When to declare a row

Write one explicitly for:

  • public library signatures whose capability set is part of the contract;
  • callback parameters and generic adapters (use a row variable);
  • host and compiler boundaries;
  • functions where “this is effect-free” is the point.

Leave it off for ordinary internal functions. Inference is shorter and it stays correct when the body changes.

Current precision

Two places over-approximate, and the diagnostics reflect the approximation rather than runtime behaviour:

  • select records Spawn, Suspend, and Cancel, even though a source-level selection spawns nothing and cancels nothing. A select with a default arm uses a non-blocking poll and still carries the full row.
  • Spawn bodies are checked inside the enclosing function, so an effect used only inside a child can widen the parent’s inferred row.
fn work(): int { return 1 }

fn dispatch(): int using [Spawn, Suspend, Cancel] {
    a := spawn { work() }
    b := spawn { work() }
    mut chosen := 0
    select {
        v := a => chosen = v
        v := b => chosen = v
    }
    return chosen
}

dispatch never calls cancel, but the declared row needs Cancel to stay warning-free. Treat the explicit row as the contract, and let the diagnostics tell you which capability the current analysis wants.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close