Effects
Algebraic effects for IO, errors, and more.
The Problem
One question has haunted programming since the beginning: how do you test code that talks to a database, reads a file, or makes a network call? The usual answer is mocking frameworks, dependency injection containers, and layers of interfaces. It works, and it buries a simple idea under an enormous amount of ceremony.
Loon takes a different path. With algebraic effects, a function declares what it needs — "I want to read from a store" — and says nothing about how that happens. The caller supplies the how. It sounds abstract on the page; it becomes obvious the moment you see it run.
Declaring Effects
An effect is a set of operations with no implementation behind them. Picture an interface or a trait, stripped down to its essence.
LOON
[effect Store
[fn get [key]]
[fn set [key value]]]This declares that there is a thing called Store with get and set operations, and nothing more. Where the data lives — memory, disk, the far side of a network — is left wide open. That choice belongs to whoever handles the effect.
Performing Effects
With an effect declared, you call its operations through dot syntax, the same way you would call a method. Nothing about the call site hints that anything unusual is happening.
LOON
[fn save-user [name]
[Store.set "user" name]
[Store.get "user"]]save-user has no idea how Store works, and it never asks. It states what it wants and trusts someone else to deliver it. That gap between intent and mechanism is the whole idea.
Handling Effects
A handle block intercepts effect operations and gives them concrete behavior. This is where an abstract effect meets a real implementation.
LOON
[handle [save-user "Alice"]
[Store.get key] [get db key]
[Store.set key val] [insert db key val]]Each clause pattern-matches an effect operation and supplies its logic. When save-user calls Store.set, execution jumps to the matching clause, runs it, and hands the result back to the function as if it had been there all along.
Resume
Every handler receives an implicit resume function that continues execution at the exact point the effect was performed. This is the single feature that sets effects apart from exceptions.
LOON
[handle [do [Store.set "x" 1] [Store.get "x"]]
[Store.get key] [resume [get state key]]
[Store.set key val] [resume None]]An exception is a one-way door: the stack unwinds, your place is gone. An effect leaves the door open. Call resume and you step back through to the precise spot you left, carrying a value with you. The effectful function never learns it was interrupted. If throwing is jumping out, resuming is jumping back in.
Tip
Calling resume is what makes effects different from exceptions. Execution continues where it left off, as if the effect operation simply returned a value.
Multi-shot Continuations
Here effects leave exceptions behind entirely. A handler is under no obligation to call resume exactly once. It can resume zero times and abort, once for the ordinary case, or many times — and every resume replays the rest of the computation from the point the effect was performed.
Resume more than once and a single program becomes a search. Say an effect asks for a boolean choice, and the handler resumes once with true and once with false. The continuation runs twice, once down each branch, and the handler gathers both answers into one result.
LOON
[effect Choice
[fn pick []]]
[fn pair []
[let a [Choice.pick]]
[let b [Choice.pick]]
#[a b]]
; resume twice per pick — explore every combination
[handle [pair]
[Choice.pick] [concat [resume true] [resume false]]
[return v] #[v]]The same machinery powers backtracking solvers and generators, and — when the continuation is captured and stored rather than run on the spot — durable replay: a computation paused, written to disk, and resumed days later from exactly where it stopped.
Tip
Loon's continuations are multi-shot on the default runtime. A handler clause may call resume as many times as it likes; each call gets its own independent copy of the rest of the computation.
Handler Towers
Because a function never names the handler that serves its effects, you can wrap the very same code in different handlers. This is the most useful consequence of the whole design: one program, many runtimes, chosen at the call site.
A tower is the stack of handlers wrapping a program. Swap the tower and the program behaves completely differently, while the program itself is never touched:
- Production — handlers perform real IO: open sockets, read files, call the clock.
- Test — handlers serve scripted inputs and record outputs, so a run is deterministic and offline.
- Replay — handlers feed a recorded journal back in, reconstructing a past run exactly.
LOON
; the program — written once, knows nothing about its tower
[fn handler [method path]
[let id [IO.uuid]]
[Log.info [str method " " path " " id]]
[str "ok " id]]
; production tower: real clock, real ids, real logging
[with-prod [fn [] [handler "GET" "/"]]]
; test tower: pinned id, captured log — identical program
[with-test [fn [] [handler "GET" "/"]]]The HTTP and agent frameworks in src/http and src/agent rest entirely on this idea. A route handler or an agent loop is plain effectful code; the prod, test, and replay towers decide whether those effects reach the network and a live model, or a fixture and a recording.
Built-in Effects
A few effects come built in, covering the operations almost every program reaches for, so you never have to redeclare them yourself.
IO
The IO effect covers the essentials: printing, reading files, reaching the network. Anything that touches the world beyond the program passes through IO.
LOON
[IO.println "hello"]
[let contents [IO.read-file "data.txt"]]Fail
The Fail effect represents recoverable errors. Rather than throw an exception that might slip past every catch, you perform a Fail effect, and a handler somewhere up the call stack decides what to do about it.
LOON
[fn divide [a b]
[if [= b 0]
[Fail.raise "division by zero"]
[/ a b]]]The ? Operator
Unwrapping Results by hand grows tedious quickly. The ? operator is sugar for the common case: it unwraps a Result on success and performs Fail on error. If you know the "try" pattern from Rust or Swift, this is Loon's take on it.
LOON
[fn load-config [path]
[let text [IO.read-file path]?]
[parse-json text]?]Without ?, every Result demands its own match and its own explicit error branch. With it, the happy path reads straight down the page, and errors find their way out on their own.
Effect Annotations
By convention, a function that performs effects ends its name with !. The compiler does not require it, but everyone reading your code later will be glad you followed it.
LOON
[fn save-user! [name]
[Store.set "user" name]]A ! at the end of a name tells you, at a glance, that the function does more than compute. It might write to a database, print to the console, or launch a rocket. The bang is a quiet promise between authors: this one has side effects, handle with care.
Testing with Effects
This is where algebraic effects pay for themselves. Testing effectful code costs almost nothing, because testing is only a matter of swapping in a different handler. No mocking libraries. No dependency injection frameworks. No test double that implements twelve interface methods when you care about exactly two.
LOON
[test "save-user stores correctly" []
[let mut log #[]]
[handle [save-user "Bob"]
[Store.set k v] [do [push! log v] [resume None]]
[Store.get k] [resume "Bob"]]
[assert-eq log #["Bob"]]]In production the handler talks to a real database. In the test it records each call into a vector. The function under test is byte-for-byte the same in both runs, blind to who is serving its effects. That blindness is the entire point.