Algebraic Effects
Algebraic Effects
Section titled “Algebraic Effects”The compiler proves what your function does to the world.
A Janus function signature is honest along three axes:
- What does it touch? — parameter intents (
view/edit/take/make) - What authority does it carry? — reference capabilities (
iso/trn/val/ref/box/tag) - What side effects does it perform? — effect rows (
!{IO, Net, Alloc, ...})
This page is the reference for the third leg.
Spec anchor: SPEC-090 (Algebraic Effects & Profile-Ambient Handlers). SPEC-090 partially supersedes SPEC-030 (the surface migrated; the semantic foundation — static dispatch, monomorphization, capability bridging, the §3.4.5 visitor API — is retained verbatim).
The !{Effects} return-type modifier
Section titled “The !{Effects} return-type modifier”A function that performs effects declares them as a brace-delimited row immediately after the return type, optionally after an error union:
func name(params) -> ReturnType !ErrorType !{Effect1, Effect2, ...} do bodyend// Pure function — no effects, no errorsfunc add(a: i64, b: i64) -> i64 do return a + bend
// Single effectfunc read_config(view path: [u8]) -> Config !{IO} do let bytes = IO.stdout_write("loading config") return parse(bytes)end
// Error union + effect rowfunc fetch_blob(view cid: [32]u8, allocator: std.mem.Allocator) -> [u8] !FetchError !{IO, Net, Alloc} do // ...end
// Multiple effects — order is not significantfunc game_turn(view player: [u8]) -> i64 !{IO, Random} do let roll = Random.next_int(6) + 1 IO.stdout_write(player ++ " rolled: " ++ str(roll)) return rollendThe two surfaces of return type
Section titled “The two surfaces of return type”| Surface | Meaning |
|---|---|
-> T | returns value |
!E | may fail with error |
!{Effects} | may perform effects |
Errors and effects share return-type position but not ontology — errors are control/result alternatives, effects are world-contact obligations.
!{} — explicit purity assertion
Section titled “!{} — explicit purity assertion”A function with !{} and a function omitting the row are not semantically identical:
// Pure-by-default — adding `IO.stdout_write(...)` later just changes the// inferred row. The function silently grows new effects as you edit it.func process(view bytes: [u8]) -> Result do ... end
// Explicit purity assertion — adding any effect later produces E2509.// The contract IS the closed purity set; modification requires signature change.pub func canonical_hash(view bytes: [u8]) -> [32]u8 !{} do return blake3(bytes)end!{} is for the contract surface (public API, audit-critical functions, pinned-purity utilities); the no-row form is for everyday composition. Two ways to say “pure” exist precisely because the intent differs.
Order doesn’t matter
Section titled “Order doesn’t matter”Effects compose via comma in the row. !{IO, Net} ≡ !{Net, IO}. Duplicates within a row are deduplicated and emit warning W2516 (duplicate effect in row — likely a copy-paste error or stale merge artifact). Silent deduplication would violate Revealed Complexity.
handle ... with E do ... end — effect handlers
Section titled “handle ... with E do ... end — effect handlers”Effect handlers use do...end block form (Law 1, control flow):
handle do handled_expressionend with EffectName do func operation_name(params) -> ReturnType do handler_body end func other_operation(params) -> ReturnType do handler_body endendThe handler body is a collection of function definitions, not a data table. Each handler is a piece of imperative code. The func keyword inside the handler body anchors each operation handler in the same syntactic shape as any other Janus function.
// Handle IO with real stdiohandle do read_config("janus.toml")?end with IO do func stdin_read(edit buf: [u8]) -> usize do return std.os.read(0, buf) end func stdout_write(view bytes: [u8]) -> void do let _ = std.os.write(1, bytes) endend
// Multiple effects on the same handlehandle do main_logic()end with IO do func stdout_write(view bytes: [u8]) -> void do log_buffer.append(bytes) endend with Time do func now() -> i64 do return mock_clock_value endendHandler scoping rules
Section titled “Handler scoping rules”- The body of
handle do ... endis walked with the listed effects in handler scope; their use is intercepted by the handler. - Each handler body itself is not in its own protective scope — performing the same effect inside the handler body is a recursive call to the handler, not a no-op.
- The innermost handler wins for any given effect (lexical inheritance).
handle is an expression
Section titled “handle is an expression”handle do ... end produces a value — the value of the handled expression. It composes inside let, function arguments, and any other expression position:
let parsed = handle do read_and_parse("config.toml")?end with IO do func stdin_read(edit buf: [u8]) -> usize do return 0 end func stdout_write(view bytes: [u8]) -> void do endendProfile-ambient handler tables
Section titled “Profile-ambient handler tables”Every profile carries a profile-ambient handler table — the set of effects whose handlers the runtime installs by default at program entry:
| Profile | Ambient effects |
|---|---|
:core / :s0 | (empty — pure-by-default) |
:service | IO, Alloc, Net, Time |
:cluster | (empty — Send/Recv/Spawn pending SPEC-021 ratification) |
:compute | (empty — GPU/NPU pending SPEC-017-P device dispatch) |
:sovereign | (empty — Boot/MMU/Hardware pending SPEC-080 §4.4) |
An effect that reaches main and is not in the ambient table must be wrapped in an explicit handle...with block; otherwise E2511 fires:
// Under :core (empty ambient), this fires E2511.// Under :service (IO is ambient), this is fine.func writes_io() -> i32 !{IO} do return 1end
func main() -> i32 do return writes_io()endThe fix under :core:
func main() -> i32 do return handle do writes_io() end with IO do func op() -> i32 do return 0 end endendDoctrine: profiles scale proof obligations and ambient powers, not truth. Effect syntax is uniform across every profile. The
:scriptlesson from SPEC-085 §2.8.2 is reapplied: language-surface constructs must not lie. Profile differences live in ambient tables and capability availability, not in language gating.
?E — narrow-form effect polymorphism
Section titled “?E — narrow-form effect polymorphism”Higher-order parameters propagate their callee’s effects through the wrapper:
func map[T, U](view xs: [T], view f: func(T) -> U !{?E}) -> [U] !{?E} do var result: [U] = [] for x in xs do result.append(f(x)) end return resultendWhen map is called with f typed func(T) -> U !{IO}, the call site’s ?E resolves to IO, and the call’s effective row is !{IO}. When f is pure, ?E resolves to the empty row.
What ?E is NOT
Section titled “What ?E is NOT”?E is not unrestricted row polymorphism. The compiler rejects:
?Emixed with concrete effects in the same row → E2512- Row arithmetic on
?E(?E - X,?E & X) → E2514 - Multiple
?Ein the same row sharing a name → E2515 (use distinct names —?Ef,?Eg) ?Ewith bound constraints (!{?E where ?E ⊆ {IO}}) → E2517?Edeclared in a row without a higher-order parameter to source it → E2513
?E is restricted to call-site monomorphization through higher-order parameters. Every ?E MUST be sourced by a function-typed parameter’s effect row.
Why this restriction
Section titled “Why this restriction”Unrestricted row polymorphism brings:
- Row variables across arbitrary boundaries
- Row unification, effect subsumption
- Inference-heavy diagnostics
- Koka-style complexity
The narrow form admits map, filter, fold, callbacks, handlers, middleware, actor hooks, scheduling combinators — every higher-order combinator that needs to thread its callee’s effects without committing to a specific row.
?E is not academic spice. It is stdlib survival.
Effects vs capabilities
Section titled “Effects vs capabilities”These are different concerns.
| Question | Answered by |
|---|---|
| What can this computation do? | Effects (!{FileRead}) |
| Who authorized this realization of that effect? | Capabilities (CapFsRead) |
A function may declare !{FileRead} without holding CapFsRead. A handler that realizes FileRead against the actual filesystem requires CapFsRead. A test handler that reads from an in-memory table requires no capability.
This separation is the doctrinal core of algebraic effects in Janus: what a function does (its effects) is decoupled from how those effects are fulfilled (capabilities). The same function can be tested purely and run in production with real authority.
The strategic line: Effects name the debt. Capabilities authorize payment. Totality proves the ledger closes.
Static-analysis tripod
Section titled “Static-analysis tripod”After SPEC-090, three queries answer the tripod:
What does it touch? — SPEC-085 parameter intentsWhat authority does it carry? — SPEC-029 reference capabilitiesWhat side effects does it perform? — SPEC-090 effect rowsEvery Janus function signature is honest along all three axes. The compiler proves the claims via:
- Parameter-passing analysis (intents)
- Cross-actor send-safety (capabilities)
- Call-graph effect propagation (effects)
After this release the static-analysis hole is closed. Profile manifests stop being aspirational and become enforceable:
| Question | Before SPEC-090 | After SPEC-090 |
|---|---|---|
Does f mutate its view parameter? | YES — SPEC-085 | YES — SPEC-085 |
Can f send iso data across actor boundaries? | YES — SPEC-029 | YES — SPEC-029 |
What syscalls does f transitively invoke? | PARTIALLY — SPEC-080 §4.4 only | YES — SPEC-080 + SPEC-090 visitor unification |
Does f allocate? | NO | YES — !{Alloc} ∈ effect_set_of(f) |
Does f perform IO above the syscall layer? | NO | YES — !{IO} ∈ effect_set_of(f) |
Is f deterministic? | NO | YES — effect_set_of(f) ∩ {Time, Random, IO} = ∅ |
Can :core build refuse a transitive !{Net} call? | NO | YES — :core ambient is empty; E2511 catches it |
Effect declarations
Section titled “Effect declarations”An effect declaration defines a named set of operation signatures:
effect IO { func stdin_read(edit buf: [u8]) -> usize func stdout_write(view bytes: [u8]) -> void}
effect Net { func send(view sock: Socket, view bytes: [u8]) -> !usize func recv(edit sock: Socket, edit buf: [u8]) -> !usize}
effect Alloc { func alloc(size: usize, align: usize) -> *u8 func free(take ptr: *u8) -> void}Effect operation signatures use the SPEC-085 intent qualifiers natively. The two systems compose orthogonally: an effect describes what side effect occurs; the operation’s parameter intents describe how each argument is passed to the handler.
Affine-ledger semantics across the effect-call boundary
Section titled “Affine-ledger semantics across the effect-call boundary”When an effect operation declares a take parameter (or operates on an iso T / ~T argument), the caller’s affine binding is consumed at the effect-call site, not at the handler-body invocation site. The handler is opaque to the caller — its body may transfer, retain, free, or queue the value, but the caller’s ledger updates the moment the effect operation is called:
effect Alloc { func free(take ptr: *u8) -> void}
func release(take ptr: *u8) -> void !{Alloc} do Alloc.free(ptr) // ptr's ledger transitions to Dead at THIS call, // regardless of what the handler body does. // ptr.read() // ❌ E2602: use of consumed binding (SPEC-029)endDeclaration rules
Section titled “Declaration rules”- Effect names MUST be PascalCase; operation names MUST be snake_case.
- Effect operations MAY accept parameters and MAY return values.
- Default implementations are FORBIDDEN — effects are pure contracts.
- Effect names and module names share a namespace; collision produces E2508 at the first observation point.
Diagnostics
Section titled “Diagnostics”| Code | Meaning |
|---|---|
| E2502 | Incomplete handler — missing operation (substrate-blocked v1.0) |
| E2509 | Function declares !{} (purity assertion) but body introduces effects |
| E2510 | Multi-effect single with clause (with E1, E2 do is forbidden) |
| E2511 | Effect reaches main but is not in the active profile’s ambient handler table |
| E2512 | ?E mixed with concrete effect in the same row |
| E2513 | ?E polymorphism variable has no source — function has no higher-order parameter to bind it |
| E2514 | Row arithmetic (subtraction, intersection) on polymorphism variable |
| E2515 | Multiple ?E polymorphism variables — use distinct names |
| E2516 | Effect handler body declares its own effect row using row arithmetic |
| E2517 | Bounded row variable / row-polymorphism-with-constraints |
| W2509 | SPEC-030 with clause — deprecated; rewrite as !{...} row |
| W2516 | Duplicate effect in row — likely a copy-paste error |
Resource API capability variants
Section titled “Resource API capability variants”Resource-touching standard library APIs expose explicit capability-token variants. The bare compatibility form may still exist while older code migrates, but the authority-bearing form is the canonical service boundary.
use std.os.capsuse std.os.fs
func read_config(cap: caps.RpathCap, path: [*:0]u8, out: [*]u8, len: i64) -> i64 do return fs.read_file_sovereign(cap, path, out, len)endCurrent token families include filesystem promises (RpathCap, WpathCap,
CpathCap), network promises (InetCap, UnixCap, DnsCap), process
promises (ProcCap, ExecCap), and executor tokens (ExecCap,
ThreadedCap, UringCap in std.exec.caps).
Use the lint gate when changing resource facades:
janus lint --ambient-authorityIt fails when a tracked resource API lacks a _cap or _sovereign variant.
Compilation strategy
Section titled “Compilation strategy”Effects compile via static dispatch through monomorphization. No continuations, no coroutines, no CPS transform, no runtime effect stack. The handler is resolved at compile time and inlined as a direct function call.
For every EffectName.operation(args) call site:
- Walk outward from the call site, looking for the innermost enclosing
handle...with EffectNameblock. - If found, replace the call with a direct call to the matching handler operation function.
- If not found, look up the active profile’s ambient handler table.
- If still not resolved at
main, emit E2511.
After lowering, effect-typed code is indistinguishable from hand-written direct-call code. Effect rows are fully erased — they exist only for the compile-time proof.
This zero-runtime-cost stance is doctrinal. Janus does not adopt OCaml-style runtime continuation machinery; that path is closed without an explicit future SPEC reopening it.
Migration from SPEC-030
Section titled “Migration from SPEC-030”The SPEC-030 with E clause syntax is deprecated. During the migration window (v2026.5.X → v2026.7.X), the parser accepts the legacy form and rewrites to !{E} with W2509:
// Old (SPEC-030, deprecated, surfaces W2509):func legacy() -> i32 with IO do return 0end
// New (SPEC-090, canonical):func canonical() -> i32 !{IO} do return 0endAfter v2026.7.X the with clause produces a parse error.
See the migration tutorial for a step-by-step walkthrough.
Visitor API for stdlib extensions
Section titled “Visitor API for stdlib extensions”The compiler exposes a stable visitor API at compiler/libjanus/effect_graph.zig::EffectVisitorRegistry for compiler-internal consumers. The first concrete consumer is SPEC-080’s @syscall_class aggregator:
@syscall_class(stdio)pub func write(fd: Fd, buf: []const u8) -> Result[usize, Errno] do // ...endUnder :sovereign (the visitor’s profile gate), every call to a @syscall_class-annotated function contributes Syscall.<class> to the calling function’s inferred effect set.
The Janus-side surface (std.compiler.effects) declares the contract types and the registration entry point; Janus-bodied visitor execution lands with Phase D’s comptime evaluator integration. Compiler-internal consumers register Zig-side via EffectVisitorRegistry.register().
Further reading
Section titled “Further reading”- Tutorial: Algebraic Effects — beginner-level walkthrough with worked examples.
- Release notes — v2026.5.10 SPEC-090 Phase C — what shipped in this release.
- SPEC-090 (Algebraic Effects & Profile-Ambient Handlers) — the normative specification.
- SPEC-030 (User-Defined Effects & Typed Handlers) — the predecessor; semantic foundation retained verbatim.
- SPEC-080 (Sovereign Pledge & Unveil) — the canonical first consumer of the effect graph.