Skip to content

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).


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
body
end
// Pure function — no effects, no errors
func add(a: i64, b: i64) -> i64 do
return a + b
end
// Single effect
func read_config(view path: [u8]) -> Config !{IO} do
let bytes = IO.stdout_write("loading config")
return parse(bytes)
end
// Error union + effect row
func fetch_blob(view cid: [32]u8, allocator: std.mem.Allocator)
-> [u8] !FetchError !{IO, Net, Alloc} do
// ...
end
// Multiple effects — order is not significant
func game_turn(view player: [u8]) -> i64 !{IO, Random} do
let roll = Random.next_int(6) + 1
IO.stdout_write(player ++ " rolled: " ++ str(roll))
return roll
end
SurfaceMeaning
-> Treturns value
!Emay 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.

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.

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_expression
end with EffectName do
func operation_name(params) -> ReturnType do
handler_body
end
func other_operation(params) -> ReturnType do
handler_body
end
end

The 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 stdio
handle 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)
end
end
// Multiple effects on the same handle
handle do
main_logic()
end with IO do
func stdout_write(view bytes: [u8]) -> void do
log_buffer.append(bytes)
end
end with Time do
func now() -> i64 do
return mock_clock_value
end
end
  • The body of handle do ... end is 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 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 end
end

Every profile carries a profile-ambient handler table — the set of effects whose handlers the runtime installs by default at program entry:

ProfileAmbient effects
:core / :s0(empty — pure-by-default)
:serviceIO, 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 1
end
func main() -> i32 do
return writes_io()
end

The fix under :core:

func main() -> i32 do
return handle do
writes_io()
end with IO do
func op() -> i32 do return 0 end
end
end

Doctrine: profiles scale proof obligations and ambient powers, not truth. Effect syntax is uniform across every profile. The :script lesson 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.


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 result
end

When 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.

?E is not unrestricted row polymorphism. The compiler rejects:

  • ?E mixed with concrete effects in the same row → E2512
  • Row arithmetic on ?E (?E - X, ?E & X) → E2514
  • Multiple ?E in the same row sharing a name → E2515 (use distinct names — ?Ef, ?Eg)
  • ?E with bound constraints (!{?E where ?E ⊆ {IO}}) → E2517
  • ?E declared 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.

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.


These are different concerns.

QuestionAnswered 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.


After SPEC-090, three queries answer the tripod:

What does it touch? — SPEC-085 parameter intents
What authority does it carry? — SPEC-029 reference capabilities
What side effects does it perform? — SPEC-090 effect rows

Every 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:

QuestionBefore SPEC-090After SPEC-090
Does f mutate its view parameter?YES — SPEC-085YES — SPEC-085
Can f send iso data across actor boundaries?YES — SPEC-029YES — SPEC-029
What syscalls does f transitively invoke?PARTIALLY — SPEC-080 §4.4 onlyYES — SPEC-080 + SPEC-090 visitor unification
Does f allocate?NOYES — !{Alloc} ∈ effect_set_of(f)
Does f perform IO above the syscall layer?NOYES — !{IO} ∈ effect_set_of(f)
Is f deterministic?NOYES — effect_set_of(f) ∩ {Time, Random, IO} = ∅
Can :core build refuse a transitive !{Net} call?NOYES — :core ambient is empty; E2511 catches it

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)
end
  • 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.

CodeMeaning
E2502Incomplete handler — missing operation (substrate-blocked v1.0)
E2509Function declares !{} (purity assertion) but body introduces effects
E2510Multi-effect single with clause (with E1, E2 do is forbidden)
E2511Effect 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
E2514Row arithmetic (subtraction, intersection) on polymorphism variable
E2515Multiple ?E polymorphism variables — use distinct names
E2516Effect handler body declares its own effect row using row arithmetic
E2517Bounded row variable / row-polymorphism-with-constraints
W2509SPEC-030 with clause — deprecated; rewrite as !{...} row
W2516Duplicate effect in row — likely a copy-paste error

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.caps
use 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)
end

Current 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:

Terminal window
janus lint --ambient-authority

It fails when a tracked resource API lacks a _cap or _sovereign variant.

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:

  1. Walk outward from the call site, looking for the innermost enclosing handle...with EffectName block.
  2. If found, replace the call with a direct call to the matching handler operation function.
  3. If not found, look up the active profile’s ambient handler table.
  4. 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.


The SPEC-030 with E clause syntax is deprecated. During the migration window (v2026.5.Xv2026.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 0
end
// New (SPEC-090, canonical):
func canonical() -> i32 !{IO} do
return 0
end

After v2026.7.X the with clause produces a parse error.

See the migration tutorial for a step-by-step walkthrough.


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
// ...
end

Under :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().


  • 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.