Algebraic Effects
Algebraic Effects
Section titled “Algebraic Effects”Make every side effect visible at the function signature.
Boundary note: ordinary Janus application code should prefer
use std.*. Any rawuse zig "std/..."examples in this tutorial are legacy patterns under migration, not the target style.
Time: 50 minutes Level: Intermediate Prerequisites: Hello World to Production, Error Handling What you’ll learn: Effect rows, handlers, profile-ambient tables, and how Janus closes the third leg of the static-analysis tripod
Why Effects Matter
Section titled “Why Effects Matter”Imagine reading this Go code:
func processUser(id string) (User, error) { return loadUser(id)}What does it do?
- Does it touch the filesystem? Maybe.
- Does it call the network? Possibly.
- Does it allocate memory? Probably.
- Does it read the system clock? Who knows.
The signature is silent on all of these. You have to read the body — and then read every function the body calls, transitively, to find out.
What makes Janus different:
pub func process_user(view id: [u8]) -> User !UserError !{IO, Net, Alloc} do return load_user(id)endThe signature confesses everything:
- Returns a
User - May fail with
UserError - May perform
IO,Net, orAlloceffects
If load_user later grows a Time effect, the compiler forces you to update process_user’s row — or wrap the call in handle ... with Time do ... end. Either way, the signature stops lying.
This is the third leg of the static-analysis tripod:
What does it touch? — parameter intents (view/edit/take)What authority does it carry? — reference capabilities (iso/trn/val)What side effects does it perform? — effect rows (!{IO, Net, ...})Step 1: Pure-by-Default (5 min)
Section titled “Step 1: Pure-by-Default (5 min)”Janus functions are pure unless they say otherwise. No row means no effects:
func add(a: i64, b: i64) -> i64 do return a + bend
func factorial(n: u64) -> u64 do if n <= 1 do return 1 end return n * factorial(n - 1)endThese touch nothing outside their parameters and return values. A future maintainer can refactor them with confidence.
Try it
Section titled “Try it”Save this as pure.jan:
func double(x: i64) -> i64 do return x * 2end
func main() -> i32 do let _ = double(21) return 0endCompile and run:
janus build pure.jan ./pure./pureecho "exit: $?"Output:
exit: 0Nothing surprising. Now let’s add a side effect.
Step 2: Adding an Effect (10 min)
Section titled “Step 2: Adding an Effect (10 min)”Suppose double should log its result:
func double(x: i64) -> i64 do println("doubling: ", x) return x * 2endprintln performs IO. The compiler now demands honesty:
janus build pure.jan ./pureYou’d see the compiler propagate the IO effect into double’s inferred row. To make the contract explicit, you write the row:
func double(x: i64) -> i64 !{IO} do println("doubling: ", x) return x * 2endNow the signature reads: returns i64, may perform IO.
The 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 |
You can stack them:
// Returns Config, may fail with FetchError, may perform IO + Net + Alloc.func fetch_config(view url: [u8]) -> Config !FetchError !{IO, Net, Alloc} do let response = try http.get(url) return try parse_config(response.body)endThe order is fixed: -> T !ErrorType !{Effects}.
Multiple effects
Section titled “Multiple effects”Effects compose via comma. Order inside the braces doesn’t matter:
func game_turn(view player: [u8]) -> i64 !{IO, Random} do let roll = Random.next_int(6) + 1 println(player, " rolled: ", roll) return rollend!{IO, Random} ≡ !{Random, IO}.
Duplicates trigger W2516:
// W2516: duplicate effect 'IO' in row — likely a copy-paste errorfunc confused() -> i32 !{IO, IO} do ... endThe duplicate is silently deduped, but the warning fires per Revealed Complexity — copy-paste errors should not be quiet.
Step 3: Handling Effects (10 min)
Section titled “Step 3: Handling Effects (10 min)”A handle ... with E do ... end block intercepts effect operations within its body and routes them to user-supplied handlers.
effect Console { func print(view msg: [u8]) -> void func read_line(edit buf: [u8]) -> usize}
func greet() -> void !{Console} do Console.print("hello, world")end
func main() -> i32 do handle do greet() end with Console do func print(view msg: [u8]) -> void do std.io.stdout_write(msg) std.io.stdout_write("\n") end func read_line(edit buf: [u8]) -> usize do return std.io.stdin_read(buf) end end return 0endThe handler body is a collection of func definitions — one per operation declared in the effect. Each handler is imperative code, not a data table.
Why do...end, not { ... }
Section titled “Why do...end, not { ... }”SPEC-030’s brace-delimited form ({ Console.print(msg) => io.stdout.write(msg), ... }) treated handlers as data. SPEC-090 treats them as code, consistent with Janus’s Law 1 (do...end for control flow). The func keyword inside the handler body anchors each operation handler in the same syntactic shape as any other Janus function.
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 endendMultiple effects on the same handle
Section titled “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) endend with Time do func now() -> i64 do return mock_clock_value endendEach with EffectName clause handles exactly one effect. Multi-effect-per-clause (with IO, Time do ...) is forbidden — produces E2510.
Step 4: Pure Testing (10 min)
Section titled “Step 4: Pure Testing (10 min)”Effect handlers are the secret weapon for testing. The same function runs against:
- Real handlers in production (real stdio, real clock, real filesystem)
- Mock handlers in tests (in-memory buffers, fake clocks, fake networks)
effect Time { func now() -> i64}
effect Console { func print(view msg: [u8]) -> void}
func log_event(view name: [u8]) -> void !{Time, Console} do let t = Time.now() Console.print("[") Console.print(str(t)) Console.print("] ") Console.print(name) Console.print("\n")endIn production:
func main() -> i32 do handle do log_event("startup") end with Time do func now() -> i64 do return std.time.unix_now() end end with Console do func print(view msg: [u8]) -> void do let _ = std.io.stdout_write(msg) end end return 0endIn a test:
test "log_event prints expected format" do var captured: ByteBuffer = ByteBuffer.empty() var fake_time: i64 = 1234567890
handle do log_event("startup") end with Time do func now() -> i64 do return fake_time end end with Console do func print(view msg: [u8]) -> void do captured.append(msg) end end
assert(captured.contains("[1234567890] startup\n"))endSame function. Different handlers. No mocking framework, no dependency injection container, no monkey-patching. The compiler resolves handlers at the call site by static dispatch — the handler in lexical scope wins.
This separation is the doctrinal core: what a function does (its effects) is decoupled from how those effects are fulfilled (handlers).
Step 5: Profile-Ambient Tables (5 min)
Section titled “Step 5: Profile-Ambient Tables (5 min)”Every profile carries a profile-ambient handler table — the set of effects the runtime auto-installs at program entry:
| Profile | Ambient effects |
|---|---|
:core / :s0 | (empty — pure-by-default) |
:service | IO, Alloc, Net, Time |
:cluster | (empty — pending SPEC-021 ratification) |
:compute | (empty — pending SPEC-017-P) |
:sovereign | (empty — pending SPEC-080 §4.4) |
Under :service, you don’t need to write handle ... with IO for main to print to stdout — the profile installs that handler for you.
Under :core, every effect must be explicitly handled:
// Under :core, this fires E2511:// effect 'IO' reaches `main` but is not in the active profile's// ambient handler table — wrap the call site in a `handle...with IO// do ... end` block (per SPEC-090 §5.1.4)func writes_io() -> i32 !{IO} do return 1end
func main() -> i32 do return writes_io()endThe fix:
func main() -> i32 do return handle do writes_io() end with IO do func op() -> i32 do return 0 end endendThe doctrine: profiles scale proof obligations and ambient powers, not truth. Effect syntax is uniform across every profile. A scripter learns effects in
:scriptexactly as a kernel engineer learns them in:sovereign— same syntax, same semantics, different ambient infrastructure.
Step 6: The Explicit Purity Assertion !{} (5 min)
Section titled “Step 6: The Explicit Purity Assertion !{} (5 min)”A function with !{} and a function omitting the row are not the same:
// Pure-by-default — effects can grow silently as you editfunc process(view bytes: [u8]) -> Result do ... end
// Explicit purity assertion — adding any effect later produces E2509pub func canonical_hash(view bytes: [u8]) -> [32]u8 !{} do return blake3(bytes)endWhy two ways to say “pure”?
- No row written — pure-by-default. Future modifications that introduce effects propagate naturally.
!{}written — explicit purity contract. Any modification that introduces an effect produces E2509 (function declares pure row '!{}' but body introduces effects). The contract IS the closed purity set.
!{} is for the contract surface — public APIs, audit-critical functions, pinned-purity utilities like cryptographic hash functions. The no-row form is for everyday composition.
Revealed Complexity, not sugar: two ways to say “pure” exist precisely because the intent differs.
Step 7: Effect Polymorphism ?E (5 min)
Section titled “Step 7: Effect Polymorphism ?E (5 min)”Higher-order combinators need to thread their callee’s effects without committing to a specific row. Janus’s narrow-form polymorphism (?E) handles this:
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 you call map 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
?Esharing a name → E2515 (use?Ef,?Eg) ?Edeclared in a row without a higher-order parameter to source it → E2513
?E is restricted to call-site monomorphization through higher-order parameters. Without it, map, filter, fold, callbacks, middleware, and scheduling combinators either become unsafely effect-erasing or unusably duplicated per effect row.
Step 8: Migration from with E (Quick reference)
Section titled “Step 8: Migration from with E (Quick reference)”The SPEC-030 with E clause syntax is deprecated. During the migration window (v2026.5.X → v2026.7.X), the parser rewrites the legacy form 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 0endFor functions with multiple effects:
// Old:func legacy() -> i32 with IO + Net do ... end
// New:func canonical() -> i32 !{IO, Net} do ... endFor functions with both an error and an effect:
// Old:func legacy() -> i32 !MyError with IO do ... end
// New:func canonical() -> i32 !MyError !{IO} do ... endAfter v2026.7.X the with clause produces a parse error. Migrate now; the warning is doing you a favour.
Common patterns
Section titled “Common patterns”Capability bridge for filesystem effects
Section titled “Capability bridge for filesystem effects”effect FileRead { func read_file(view path: [u8]) -> [u8] !FsError}
handle do let content = try FileRead.read_file("config.toml") process(content)end with FileRead do func read_file(view path: [u8]) -> [u8] !FsError do // Handler holds CapFsRead; the function declaring !{FileRead} // does NOT need to hold the capability itself. return try std.fs.read(path, fs_cap) endendThe function declaring !{FileRead} says what — the handler holding fs_cap says how.
Auditable pure-by-design
Section titled “Auditable pure-by-design”// The signature is the contract. Reviewers know at a glance: this function// touches nothing, calls only other pure functions, and any change that// introduces an effect requires a signature change (E2509).pub func canonical_hash(view bytes: [u8]) -> [32]u8 !{} do return blake3(bytes)endHigher-order combinator
Section titled “Higher-order combinator”func with_retry[T]( view f: func() -> T !RetryError !{?E}, max_attempts: u32,) -> T !RetryError !{?E} do var attempt: u32 = 0 while attempt < max_attempts do match f() do ok value => return value, err _ => attempt = attempt + 1, end end fail RetryExhaustedendwith_retry’s effect row is whatever f declares. Call it with a pure function and with_retry is pure; call it with an !{IO, Net} function and with_retry carries !{IO, Net}.
What’s next
Section titled “What’s next”- The full reference page on effects covers diagnostics, compilation strategy, and the visitor API for stdlib extensions.
- Error Handling covers the
!Errorhalf of the return-type contract. - Concurrency shows how the
:serviceprofile’s ambientNet/Timehandlers compose with structured concurrency. - The SPEC-090 release notes document what landed in this release and what’s deferred to Phase D.
The strategic line: Memory has intent. Authority has identity. Side effects have shape. The compiler proves all three.