Abilities
Partly builtAlgebraic effects: ability declarations, handlers, and the one worked example that runs end to end.
Abilities are Clay’s algebraic-effects system, carried over from Unison’s own
ability model. An ability declares a set of operations; a definition that calls
one of them must say so, in a <...> list on its own name; and a handle block
supplies an implementation, the same way an exception handler supplies a catch.
Abilities landed 2026-09-25 (grammar, compiler wiring, checker) and have grown since:
resume, parameterised handlers and ability polymorphism are built, and the built-in
IO ability is what a program’s main ultimately needs (see
Printing and the clock). Everything shown here is oracle-verified.
The worked example
This is real Clay source. It declares a Console ability with one operation,
defines hello, which requires Console, and greet, which provides a handler
for it — and it has actually been added, verified and evaluated through Clay’s
own tools, checked against UCM at every step:
ability Console {
print: string => unit
}
let hello<Console> = (name: string): unit =>
Console.print("Hi " ++ name)
let greet: string =
handle hello("World") {
done(x) => "done"
Console.print(msg, resume) => msg
}A few things worth calling out about the syntax:
- The
<Console>list goes on the binder, right after the name and before the parameter list — never a bare prefix, never leading an expression (an expression-leading<is reserved for a future JSX-like syntax). Console.print(...)is a narrow, specific call form — a known ability name, a known operation name, then arguments — not a general “dot access” the language doesn’t otherwise have.- A handler’s value clause is spelled
done(x), deliberately notreturn(x), so it never collides with a futurereturnkeyword. - Every
abilityClay emits is declared structural — its hash comes purely from its written shape, never a process-generated identifier — because that’s the only form of ability declaration that’s actually a pure function of its text. That’s a deliberate, permanent choice, not a temporary gap: it’s what keeps an ability’s identity as trustworthy as everything else that’s hashed.
An operation that never returns a value
An operation can be generic in its own result. fail below is declared <'x> string => 'x: whatever the caller needs
at that point, fail can stand in for it, because a handler that does not call resume simply never produces one.
That makes it an exception in the old sense: the handler decides what the whole handle expression is.
ability Fail {
fail: <'x> string => 'x
}
let safeDiv<Fail> = (a: int, b: int): int =>
if b == 0 { Fail.fail("divide by zero") } else { a / b }
let report: string =
handle safeDiv(1, 0) {
done(x) => "ok"
Fail.fail(msg, resume) => msg
}
let fallback: int =
handle safeDiv(8, 2) {
done(x) => x
Fail.fail(msg, resume) => 0
}The <'x> in front of the operation’s type is a binder-position list, like the one on a definition, and it is local to that
one operation (an ability’s own type parameters are on the ability). In the first handle, fail is used as an int;
in another place it could be a string or a list. Both handlers are complete: a handle needs a clause for every
operation, plus done. The standard library’s Exception ability is this same idea, with a failure value as the
message (Exception.raise(f)), and exceptionToResult turns a raise back into a result; see Results.
A handler you can name
An inline handle { ... } is fine for one use. When the same handling belongs in several places, or when it carries state, write the handler as an
ordinary function. Its argument has the type request<Counter, int>: “what the ability asks for, or the final answer (an int)”. You
take it apart with a switch whose arms are done(x) and one arm per operation, and you install it on an expression with
handle expr with handler.
ability Counter {
next: () => int
}
let counting = (n: int, req: request<Counter, int>): int => switch req {
| done(x) => x
| Counter.next(resume) => handle resume(n) with counting(n + 1)
}
let twoNexts<Counter> = (): int => Counter.next() + Counter.next()
let total = (): int => handle twoNexts() with counting(10)counting hands the current number to whoever asked and then installs itself again on what is left of the computation, one number higher. That
re-installation is the handle resume(n) with counting(n + 1) line: resume is an ordinary function, and calling it
runs the rest of the body, so it has to be handled again. The state is nothing more than the first parameter.
What’s still missing
- Ability-polymorphic functions (
<'e>, “whatever effects the caller’s callback happens to need”),resumeinside a handler clause and parameterised handlers (a State handler that threads a value) are built (2026-10-02) and agree with UCM; see the standard library’srunClockandfakeClockfor worked uses. The standard library has anExceptionability (see Results); handlers can also be written as stand-alone functions (see above). - A
handlemust have a clause for every operation of the ability it handles (the checker refuses a missing one). - A declared
<...>list must match exactly what the body performs — Unison itself is looser here (a declared-but-unused ability typechecks); Clay’s checker is intentionally stricter, so this can produce a false refusal, never a false accept. - Calling the handler’s
resumecontinuation is accepted without being fully modeled the way Unison’s own type system models it (callingresumecan, in real Unison, push the ability requirement onto the handler’s own type). Not a soundness risk — a real disagreement is still caught at write time — just a narrower checker than Unison’s in this one spot. - ▶ Run executes handlers in the page first, on the Rust engine’s explicit-stack machine (delimited, multi-shot continuations), so ability code runs offline; when a server is reachable the same program also runs through UCM and the two answers are compared.