Skip to content

Algebraic Effects

Make every side effect visible at the function signature.

Boundary note: ordinary Janus application code should prefer use std.*. Any raw use 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


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

The signature confesses everything:

  • Returns a User
  • May fail with UserError
  • May perform IO, Net, or Alloc effects

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

Janus functions are pure unless they say otherwise. No row means no effects:

func add(a: i64, b: i64) -> i64 do
return a + b
end
func factorial(n: u64) -> u64 do
if n <= 1 do return 1 end
return n * factorial(n - 1)
end

These touch nothing outside their parameters and return values. A future maintainer can refactor them with confidence.

Save this as pure.jan:

func double(x: i64) -> i64 do
return x * 2
end
func main() -> i32 do
let _ = double(21)
return 0
end

Compile and run:

Terminal window
janus build pure.jan ./pure
./pure
echo "exit: $?"

Output:

exit: 0

Nothing surprising. Now let’s add a side effect.


Suppose double should log its result:

func double(x: i64) -> i64 do
println("doubling: ", x)
return x * 2
end

println performs IO. The compiler now demands honesty:

Terminal window
janus build pure.jan ./pure

You’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 * 2
end

Now the signature reads: returns i64, may perform IO.

SurfaceMeaning
-> Treturns value
!Emay 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)
end

The order is fixed: -> T !ErrorType !{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 roll
end

!{IO, Random}!{Random, IO}.

Duplicates trigger W2516:

// W2516: duplicate effect 'IO' in row — likely a copy-paste error
func confused() -> i32 !{IO, IO} do ... end

The duplicate is silently deduped, but the warning fires per Revealed Complexity — copy-paste errors should not be quiet.


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

The handler body is a collection of func definitions — one per operation declared in the effect. Each handler is imperative code, not a data table.

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

Each with EffectName clause handles exactly one effect. Multi-effect-per-clause (with IO, Time do ...) is forbidden — produces E2510.


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")
end

In 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 0
end

In 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"))
end

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


Every profile carries a profile-ambient handler table — the set of effects the runtime auto-installs at program entry:

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

The fix:

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

The doctrine: profiles scale proof obligations and ambient powers, not truth. Effect syntax is uniform across every profile. A scripter learns effects in :script exactly 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 edit
func process(view bytes: [u8]) -> Result do ... end
// Explicit purity assertion — adding any effect later produces E2509
pub func canonical_hash(view bytes: [u8]) -> [32]u8 !{} do
return blake3(bytes)
end

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


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

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

?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 sharing a name → E2515 (use ?Ef, ?Eg)
  • ?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. 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.Xv2026.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 0
end
// New (SPEC-090, canonical):
func canonical() -> i32 !{IO} do
return 0
end

For functions with multiple effects:

// Old:
func legacy() -> i32 with IO + Net do ... end
// New:
func canonical() -> i32 !{IO, Net} do ... end

For functions with both an error and an effect:

// Old:
func legacy() -> i32 !MyError with IO do ... end
// New:
func canonical() -> i32 !MyError !{IO} do ... end

After v2026.7.X the with clause produces a parse error. Migrate now; the warning is doing you a favour.


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

The function declaring !{FileRead} says what — the handler holding fs_cap says how.

// 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)
end
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 RetryExhausted
end

with_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}.


  • The full reference page on effects covers diagnostics, compilation strategy, and the visitor API for stdlib extensions.
  • Error Handling covers the !Error half of the return-type contract.
  • Concurrency shows how the :service profile’s ambient Net/Time handlers 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.