Events Are Monads: The Free Monad at the Heart of Koru

· 15 min read

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

  1. Describe a computation as data (like an AST)
  2. Separate the description from the execution
  3. 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 path parameter”
  • “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-notationKoru continuation
x <- operationoperation ... \| branch x \|>
nextOp xnextOp(param: x.field)
Sequencing via doSequencing 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.value from the outer scope
  • d.val from the middle scope
  • f.result from 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?

Or dive into the theory:


Have thoughts on Koru’s monadic structure? We’d love to discuss! Join us on GitHub Discussions or Discord.