Understanding Effects
How algebraic effects unify IO, errors, and state.
The Problem
Side effects break the two things programmers care about most: composition and testing.
Consider the failures one at a time. A function that reads from the network will not compose cleanly with one that reads from a file, because both must reach out to the world. A function that throws will not compose with one that returns an error value, because the caller has to juggle two conventions at once. And a function that mutates global state resists testing, because you must reset that state between runs and pray nothing leaks across the seam.
The root cause never changes: the function does something beyond computing a return value, and the language offers no structured way to name that something, intercept it, or swap it out.
Traditional Solutions
Exceptions
Exceptions let you signal an error without threading return values through every layer of code. The convenience is real. But in most languages they are invisible to the type system, they bend control flow in surprising ways, and they cannot be resumed. When a function throws, it is finished. The caller can catch the exception, but it cannot hand the function a value and ask it to carry on.
Monads
Haskell's approach is to wrap effects in types like IO, State, and Either. The approach is principled, and the type system faithfully tracks which effects you use. But it carries real syntactic weight: do-notation, monad transformers, and the genuine difficulty of combining several effects at once. Transformer stacks are a recurring source of friction, even for seasoned Haskell programmers.
Async/await
Async/await solves a single effect, concurrency, and in doing so creates the "function color" problem. An async function cannot be called from a sync one without ceremony. Every function in the call chain must declare a color, and changing that choice later ripples outward through the whole codebase. You end up maintaining two parallel worlds that refuse to mix.
The Effect Solution
Algebraic effects answer every one of these problems with a single mechanism. The idea has three parts: declare, perform, handle.
Declare
You define an effect as a set of operations, the way you would declare an interface, except the interface is for side effects:
[effect Console
[fn readline [prompt] -> Str]
[fn writeline [text] -> Unit]]Perform
Code that needs the effect calls its operations directly. No special imports, no wrapping in monadic types, no ceremony:
[fn greet []
[let name [perform Console.readline "Name: "]]
[perform Console.writeline [str "Hello, " name "!"]]]Handle
A handler intercepts those operations and decides what each one means. This is where the power lives:
[handle [greet]
[Console.readline prompt] [resume "World"]
[Console.writeline text] [do
[println text]
[resume]]]The handler sees each operation as it happens and supplies an implementation. The code that performed the effect neither knows nor cares which handler is installed. It asks for a readline and receives a string.
How Resume Works
One feature sets algebraic effects apart from exceptions: resume. When a handler calls resume, it sends a value back to the exact point where the effect was performed, and the function picks up from there as though nothing had interrupted it.
[fn ask-age []
[let input [perform Console.readline "Age: "]]
[parse-int input]]
; Handler that provides a fake input for testing
[handle [ask-age]
[Console.readline _] [resume "25"]]When ask-age performs Console.readline, control transfers to the handler. The handler calls [resume "25"], which sends "25" back to ask-age as the return value of perform. The function proceeds as if a real console had handed it that string.
An exception cannot do this. It unwinds the stack and destroys the context it leaves behind. An effect handler does the opposite: it pauses the computation, supplies a value, and lets it run on. That single difference is everything.
Note
This is what makes effects testable. Swap real IO for a handler that returns canned responses, and the code under test does not change by a single character.
The Unification
The deeper payoff is that IO, errors, state, and concurrency are all the same thing: effects. None of them gets special syntax. Learn the perform-and-handle pattern once, and you have learned every one of them.
IO as an effect
File reads, network calls, and console interaction are effects. Your code performs them; a handler at the program's boundary supplies the real implementation. In tests, a different handler stands in with fake responses.
[effect Http
[fn get [url] -> Result Str HttpError]]Errors as an effect
Rather than throwing exceptions or threading Result through every call, you perform an error effect. The handler decides what failure means here: retry the operation, log it and fall back to a default, or let it propagate:
[effect Fail
[fn fail [err] -> Nothing]]
[handle [parse-config path]
[Fail.fail err] [do
[log-error err]
default-config]]State as an effect
You can model mutable state with no mutation underneath. The handler threads the state value through its resume calls, so the code reads as though it were mutating a variable, while the machinery stays purely functional:
[effect State
[fn get [] -> a]
[fn put [val] -> Unit]]Async as an effect
This may be the most elegant application of all. Loon has no function coloring, because concurrency is simply another effect. A function never declares itself async. It performs an async operation, the runtime handler manages scheduling, and sync and async code compose without a seam between them.
Research Lineage
Algebraic effects are not a Loon invention. They descend from a line of programming-language research reaching back to Plotkin and Power in 2003, and several languages have explored the idea since, each adding an insight of its own:
- Eff, the original research language for algebraic effects.
- Koka, which introduced evidence-based compilation, by Daan Leijen at Microsoft Research.
- Frank, which explored effects as an extension of call-by-push-value.
- OCaml 5, which brought shallow effect handlers to a mainstream language.
Loon builds on this work with ergonomics as its north star: full inference of effect types, so you never annotate them; concise syntax; and tight integration with ownership semantics.