Events Are Monads: The Free Monad at the Heart of Koru
“The atom is the event.” —
koruc zen
The Secret Inside Koru
Here’s something we haven’t explicitly said yet: Koru events are Free Monads.
Not “kind of like” monads. Not “inspired by” monads. They are implementations of the Free Monad pattern, complete with monadic bind, context accumulation, and composable interpreters.
This isn’t academic window dressing. The monadic structure is why Koru can:
- Capture side effects completely (no hidden I/O)
- Compose operations naturally (continuation syntax)
- Have multiple interpreters (test mocks, different backends)
- Compile to zero-cost Zig code (Free Monad compiles away)
- Make the compiler itself an event (self-hosting through monadic composition)
Let’s unpack what this means.
What’s a Free Monad?
A Free Monad is a way to:
- Describe a computation as data (like an AST)
- Separate the description from the execution
- Interpret it later in different ways
Think of it like writing a recipe vs. cooking the meal. The recipe (Free Monad) describes what to do. Different chefs (interpreters) can execute it differently—one might use gas, another electric, one might substitute ingredients. But the recipe itself is pure description.
In Haskell, you might write:
data FileOp next
= Read FilePath (String -> next)
| Write FilePath String next
| NotFound next
type FileProgram a = Free FileOp a This describes file operations without performing them. Later, you write an interpreter that actually does the I/O.
Koru Events Are Free Monad Constructors
Look at a Koru event declaration:
~event read { path: []const u8 }
| success { contents: []const u8 }
| notfound {}
| ioerror { msg: []const u8 } This is exactly a Free Monad constructor. It says:
- “I’m an operation called
read” - “I take a
pathparameter” - “I can produce three possible outcomes (constructors):
success,notfound,ioerror”
The event declaration is pure data—it describes the computation without executing anything.
Procs Are Interpreters
The proc is where execution happens:
~proc read {
// THIS is where side effects occur
const file = std.fs.cwd().openFile(path, .{}) catch {
return .{ .notfound = .{} };
};
defer file.close();
const contents = file.readToEndAlloc(allocator) catch |err| {
return .{ .ioerror = .{ .msg = @errorName(err) } };
};
return .{ .success = .{ .contents = contents } };
} The proc is the interpreter for the read event. It performs the actual I/O and returns one of the declared branches.
Crucially: you could have multiple procs for the same event. A test interpreter. A mock interpreter. A production interpreter. The Free Monad separates description from execution.
Continuations Are Monadic Bind
In Haskell, monadic bind looks like:
do
contents <- readFile "config.txt"
parsed <- parseJSON contents
validated <- validate parsed
return validated The <- is monadic bind (>>=)—it sequences computations, passing the result of one into the next.
In Koru, it looks like:
~read(path: "config.txt")
| success s |> parse(data: s.contents)
| valid v |> validate(obj: v.parsed)
| ok validated |> process(data: validated.obj)
| done |> _ The |> is monadic bind! It sequences event invocations, passing branch payloads into the next event.
Compare directly:
| Haskell do-notation | Koru continuation |
|---|---|
x <- operation | operation ... \| branch x \|> |
nextOp x | nextOp(param: x.field) |
Sequencing via do | Sequencing via nesting |
Monadic bind >>= | Continuation \|> |
Same structure. Same semantics. Monadic composition.
The Binding Scope Secret: Built-In Reader Monad
Here’s where Koru goes beyond a simple Free Monad—it has a Reader Monad built into the binding scopes.
Look at this test from the Koru regression suite (test 202_binding_scopes):
~outer(x: 10)
| result r |> middle(y: r.value)
| data d |> inner(z: d.val)
| final f |> showValues(a: r.value, b: d.val, c: f.result)
| done |> _ At the deepest level (showValues), you can access:
r.valuefrom the outer scoped.valfrom the middle scopef.resultfrom the inner scope
All bindings persist through nested continuations. This is exactly how the Reader monad works in Haskell:
do
r <- outer 10 -- r bound to context
d <- middle r.value -- r still accessible, d added to context
f <- inner d.val -- r and d still accessible, f added
showValues r.value d.val f.result -- Full context available! The continuation syntax automatically threads the context through. You get context accumulation without manual plumbing.
This is like having:
type KoruMonad r a = ReaderT (Map String Dynamic) (Free EventF) a Where:
Free EventF= the event operations (Free Monad)ReaderT (Map String Dynamic)= the accumulated bindings (Reader Monad)- Continuations thread both through automatically
Why This Changes Everything
1. Side Effects Are Captured, Not Leaked
In most languages, side effects can happen anywhere:
function processFile(path) {
const data = readFile(path); // Hidden I/O!
const parsed = JSON.parse(data); // Hidden exception!
return parsed;
} You can’t tell from the signature that this does I/O or can throw.
In Koru, the event signature declares the effects:
~event processFile { path: []const u8 }
| success { data: ParsedData }
| notfound {}
| ioerror { msg: []const u8 }
| parse_error { msg: []const u8 } Every possible outcome is declared. Side effects are captured in the event’s type.
2. Multiple Interpreters
Because events are Free Monads, you can have multiple interpreters:
Production interpreter (actual I/O):
~proc read {
const file = std.fs.cwd().openFile(path, .{}) catch {
return .{ .notfound = .{} };
};
// ... actual file reading
} Test interpreter (no I/O):
~proc read {
// Mock data for tests
if (std.mem.eql(u8, path, "test.txt")) {
return .{ .success = .{ .contents = "mock data" } };
}
return .{ .notfound = .{} };
} Same event declaration, different interpreters. This is the power of separating description from execution.
3. Composition Is Natural
Monadic composition means operations chain naturally:
~read(path: "users.json")
| success s |> parse(data: s.contents)
| valid v |> lookupUser(data: v.parsed, id: 42)
| found u |> updateEmail(user: u.data, email: "new@example.com")
| updated |> save(user: updated.user)
| saved |> log(msg: "User updated")
| done |> _
| not_found |> log(msg: "User 42 not found")
| done |> _
| invalid i |> log(msg: i.reason)
| done |> _
| notfound |> log(msg: "users.json not found")
| done |> _
| ioerror e |> log(msg: e.msg)
| done |> _ Every branch is handled. Every continuation is explicit. The flow is completely visible. This is monadic composition with exhaustive pattern matching.
4. Zero Cost Because Free Monads Compile Away
Here’s the magic: at compile time, Koru transforms these monadic compositions into efficient Zig code.
The continuation:
~read(path: "config.txt")
| success s |> parse(data: s.contents) Becomes (simplified):
const read_result = read(.{ .path = "config.txt" });
switch (read_result) {
.success => |s| {
const parse_result = parse(.{ .data = s.contents });
// ...
},
// other branches
} The Free Monad structure compiles away. No runtime overhead. No allocations. No vtables. Just efficient, inlined Zig code.
From koruc zen:
Zero cost at runtime. All magic at compile time. The boundary dissolves.
The Compiler Is a Monad
This is where things get beautifully recursive: Koru’s compiler is written in Koru using events.
The parser is an event:
~event parse { source: []const u8 }
| ast { tree: AST }
| syntax_error { msg: []const u8, location: SourceLocation } The type checker is an event:
~event typecheck { ast: AST }
| typed { typed_ast: TypedAST }
| type_error { msg: []const u8, location: SourceLocation } Code generation is an event:
~event codegen { typed_ast: TypedAST }
| generated { zig_code: []const u8 }
| codegen_error { msg: []const u8 } The entire compilation pipeline is monadic composition:
~parse(source: input)
| ast a |> typecheck(ast: a.tree)
| typed t |> codegen(typed_ast: t.typed_ast)
| generated g |> writeFile(path: output, data: g.zig_code)
| written |> _
| ioerror e |> reportError(msg: e.msg)
| codegen_error e |> reportError(msg: e.msg)
| type_error e |> reportError(msg: e.msg)
| syntax_error e |> reportError(msg: e.msg) From koruc zen:
The AST is the program. The program is the compiler. The compiler is an event.
Events all the way down. Even the compiler. Especially the compiler.
The Zen of Monads
The Free Monad structure explains everything in koruc zen:
“The atom is the event.” The fundamental unit of computation is an event (Free Monad constructor)
“Functions are just events that forgot how to branch.” A function is a degenerate monad with one outcome. Events embrace multiple outcomes.
“What you don’t write can’t break.” The event signature is pure description. Side effects only in interpreters.
“Model reality, not abstractions.” Events model real outcomes (success/notfound/ioerror), not abstract “Result” types
“Complex behavior from simple rules.” Monadic composition (simple rule: sequence events) creates complex workflows
“Zero cost at runtime. All magic at compile time.” Free Monads compile to efficient code with no abstraction overhead
Comparison to Other Languages
Haskell: Explicit Monads
readConfig :: FilePath -> IO (Either Error Config)
readConfig path = do
contents <- readFile path -- IO monad, can throw
return $ parseConfig contents Haskell has explicit monads (IO, Maybe, Either), but they’re separate types. You need monad transformers to combine them.
Koru has one monad (events) that handles all effects through branches.
Rust: No Monadic Structure
fn read_config(path: &str) -> Result<Config, Error> {
let contents = fs::read_to_string(path)?;
parse_config(&contents)
} Rust’s ? is monadic bind for Result/Option, but:
- No Free Monad structure (effects aren’t captured)
- No automatic context accumulation
- Can’t have multiple interpreters
- Collapses granular errors into one type
Koru: Free Monad + Reader Monad
~readConfig(path: "app.conf")
| success s |> parse(data: s.contents)
| valid v |> validate(config: v.parsed)
| ok validated |> applyConfig(cfg: validated.config)
| applied |> _
| invalid i |> logError(msg: i.reason)
| logged |> _
| malformed m |> logError(msg: m.reason)
| logged |> _
| notfound |> useDefaults()
| defaults |> applyConfig(cfg: defaults.config)
| applied |> _
| ioerror e |> logError(msg: e.msg)
| logged |> _ Every effect captured, every branch explicit, all bindings available, zero runtime cost.
The Vision Realized
When we say Koru is an event-driven language, we don’t mean “event-driven” like Node.js callbacks or message queues. We mean:
Events are the fundamental computational structure, implemented as Free Monads, composed through monadic bind, with automatic context accumulation, compiling to zero-cost native code.
From koruc zen:
We reached for the stars. Sometimes we grabbed them.
The Free Monad is one of the stars we grabbed. It gives us:
- Explicit control flow (no hidden effects)
- Safe composition (monadic laws guarantee correctness)
- Multiple interpreters (testing, mocking, different backends)
- Zero cost (Free Monads compile away)
- Self-hosting (the compiler is events)
Further Reading
Want to understand more about how Koru’s monadic structure works?
- Standing on Shoulders: Libero, Pieter Hintjens, and Event Continuation - The historical lineage
- Branches, Not Errors - How events handle all outcomes equally
- Optional Branches - Flexible APIs with optional outcomes
Or dive into the theory:
- Free Monads in Haskell
- Why Free Monads Matter
- The Koru SPEC.md (coming soon)
Have thoughts on Koru’s monadic structure? We’d love to discuss! Join us on GitHub Discussions or Discord.