Effect Branches: Beyond Yield

· 9 min read

A C# developer reads Koru’s ! tick i |> body for the first time and immediately recognizes it: “oh, that’s just yield return.” They’re not wrong. They’re seeing one slice of something bigger.

The slice that looks like a generator

In C#:

IEnumerable<int> Tick() {
    for (int i = 0; i < 5; i++) yield return i;
}

foreach (var i in Tick()) {
    Console.WriteLine(i);
}
Output
0
1
2
3
4

In Koru:

~pub event ticker { n: usize }
! tick usize

~proc ticker|zig {
    for (0..n) |i| tick(i);
}

~ticker(n: 5)
! tick i |> std.io:print.ln("{{ i:d }}")
Output
0
1
2
3
4

Same shape. Same output. Producer suspends on yield/effect-fire, consumer’s body runs, producer resumes. If this were all Koru’s effect branches did, they’d be a curiosity — yield with different punctuation.

It isn’t all they do.

Three things generators don’t

Multi-kind yields in one proc. A C# iterator yields one type. A Koru proc fires any number of effect kinds. The simplest extension of the ticker — add a tock:

~pub event ticker { n: usize }
! tick usize
! tock usize
| done

~proc ticker|zig {
    for (0..n) |i| {
        if (i % 2 == 0) tick(i) else tock(i);
    }
    return .{ .done = .{} };
}

~ticker(n: 5)
! tick i |> std.io:print.ln("tick {{ i:d }}")
! tock i |> std.io:print.ln("tock {{ i:d }}")
| done |> std.io:print.ln("all done")
Output
tick 0
tock 1
tick 2
tock 3
tick 4
all done

Same producer, same single loop, but now firing two distinct effects. The consumer writes a separate handler per kind — no tagged union, no switch on a discriminator, no if (event.type == ...) ladder. Each ! line is its own typed handler routed by the effect name.

The lifecycle is simple. A proc runs. While running, it fires effects — each declared ! can fire zero or more times, in any order. Each fire suspends the proc, runs the consumer’s matching handler, then returns. When the proc finishes, it breaks out into one of its declared | terminals — the matching consumer handler runs once. If no terminals are declared, the proc is void; it just ends.

The same shape scales. Here’s the entry point of a real TUI library declaring five effect kinds and two terminals:

~pub event run { title: []const u8 }
! ?ready
! ?key koru.vaxis:KeyData
! ?resize koru.vaxis:SizeData
! ?focus_in
! ?focus_out
| ?done
| err []const u8

A generator-based equivalent in C# would flatten all five into a tagged union and make the consumer switch on a discriminator at every step. Effect branches let the consumer keep them separate — closer to a visitor than an iterator.

Resume values back to the producer. When the consumer handles an effect, the expression value of the handler flows back to the producer:

const std = @import("std");

~pub event prompt_user { question: []const u8 }
! ask []const u8 -> []const u8
| done []const u8

~proc prompt_user|zig {
    std.debug.print("{s}\n", .{question});
    const reply = ask(question);
    return .{ .done = reply };
}

~prompt_user(question: "What's your name?")
! ask _ |> "Alice"
| done r |> std.io:print.ln(r)
Output
What's your name?
Alice

ask’s return inside the proc body is whatever the consumer’s handler evaluated to. C# yield return is one-directional. Python’s coroutine.send() does this; Lua coroutines do this; most mainstream yield doesn’t. Effect branches do it natively without coloring functions.

Compile-time specialization, zero runtime cost. C# IEnumerable<T> is a heap-allocated state machine. Python generators allocate generator objects. JavaScript V8 sometimes optimizes, often doesn’t. Koru handlers lower to a comptime-known struct passed as a type parameter; the producer’s tick(i) is a direct call into the consumer-supplied handler function, fully inlinable by the Zig compiler:

const Handlers_0 = struct {
    fn tick(i: usize) void {
        std.debug.print("{d}\n", .{i});
    }
};
const result = ticker_event.handler(.{ .n = 5 }, Handlers_0);

No vtable. No allocation. The whole consumer-side handler structure is comptime; the Zig compiler is free to fuse the producer’s loop and the consumer’s handler into a single function.

The actually interesting property

These three are real wins on their own. But they’re not the deep thing.

The deep thing is: an effect branch declaration is a structural type for control flow, and the type system enforces it at compile time, at every nesting level, for free.

When you write:

~pub event run { title: []const u8 }
! ?ready
! ?key koru.vaxis:KeyData
| err []const u8

You haven’t just declared an event. You’ve declared a shape — a contract that says “whoever consumes me must (optionally) provide a ready handler, (optionally) provide a key handler that takes a KeyData, and must handle an err []const u8 terminal.” There is no separate interface declaration, no trait, no protocol class. The shape is structurally part of the event. Consumers satisfy it with a comptime-known handler struct that the compiler checks against the shape.

This is structural typing applied not to data, but to control flow.

Nesting is shape composition

Here’s where it gets interesting. Effect-branch events can nest. A consumer’s handler body can fire its own event — which has its own shape — which requires its own handlers — which can fire further events. And it all collapses at compile time.

Real example from a working vaxis program (the TUI library we built using effect branches): the consumer fires three vaxis utility events from inside its handlers, and a key handler with a guard:

~koru.vaxis:run(title: "Hello")
! ready |> koru.vaxis:write_at(x: 2, y: 2, text: "Hello! Press q to quit.")
! key k when k.ch == 'q' |> koru.vaxis:quit()
| err _ |> std.io:print.ln("Failed to start vaxis")

Each of write_at, quit, and print.ln is its own event with its own shape. The ! ready |> ... body fires write_at — which has its own input contract ({ x: u16, y: u16, text: []const u8 }). The compiler checks every level. None of them allocate. The whole nested chain — vaxis’s main loop, the consumer’s handlers, the inner utility-event calls — fuses into one inlined chain of calls.

There is no “nested effect tax.” OCaml 5’s effect handlers nest, but every level pays runtime dispatch overhead. Koka does too. Free monads in Haskell give you the composition but at the cost of monadic plumbing in every line of source. Koru’s nesting is just another inlined call site.

The same property holds for deeper chains: a consumer’s handler can fire an event whose handlers themselves fire events. Each level is structurally checked at compile time, each level lowers to a comptime-known handler struct, each level is inlined. Nesting depth carries no runtime cost beyond the work the user-written handler bodies actually do.

What you can actually do with this

Once “computation kernel under a structural contract, infinitely nestable, zero cost” is your primitive, the patterns it subsumes start stacking up:

  • Generators / yield — single-kind, no resume value, zero comptime. The base case.
  • Algebraic effects (Koka, OCaml 5) — multi-kind, with resume. The sibling. Koru pays no runtime cost they pay.
  • Async/await without function coloring — a ! pending effect, consumer suspends, resumes when ready. Effekt and Koka do this; Koru gets it for free.
  • Exceptions — fire an effect whose handler doesn’t resume. The producer body past the fire never runs.
  • Visitor pattern — each effect is a visitor method; the comptime handler struct is the visitor.
  • Streaming I/O / parsers / lexers! token tok, ! error msg, ! eof. Multi-kind effect branches are the natural fit.
  • The compiler itself~std.compiler:coordinate IS an event with branches. User code can override it to intercept any stage of the pipeline. The metacircular compiler is effect branches nesting effect branches all the way down.

This is what “you are the compiler” means at the architectural level. Every level of a Koru program — leaf events, library wrappers, the compilation pipeline itself — is the same primitive. The compiler doesn’t use a different abstraction for “lexing” than you’d use for “TUI event loop.” Same shape, same checking, same code generation, all the way through.

The unique position, stated plainly

OCaml 5 has algebraic effects. They allocate at runtime.

Koka has algebraic effects. They allocate at runtime.

C# / JavaScript / Python / Kotlin have yield-style generators. They allocate state machine objects at runtime.

Rust has zero-cost iterators (chain-style combinators) and unstable, rarely-used yield-based generators (heap-allocated). The chain style is a different shape from yield; nobody actually uses Rust’s generators.

No mainstream language has zero-cost algebraic effects that subsume yield, support multi-kind dispatch, carry resume values back to the producer, and nest without a runtime tax. That’s the slot effect branches fill.

Coming next: numbers

This post is the claim. The next post is the receipts.

The benchmark suite we’re designing covers two shapes:

  • Single-kind throughput — range → filter → map → reduce, 1M values. Pure per-yield cost. Compare Koru against C# (yield return), JavaScript V8 generators, Python generators, Kotlin sequences. The chart everyone will read.
  • Multi-kind state machine — lexer over a char stream, fires ! token, ! whitespace, ! error. The chart where the multi-kind story lands.

The interesting bet is on the first chart: can a comptime-specialized handler struct match LLVM’s inlining of Rust iterator chains, while the heap-allocated state machine languages eat the alloc cost? We think yes. We’ll know soon.

Source for both workloads will be linked here when the numbers land. Until then, this is a claim with code to back the shape of it but no measured headline. Don’t take any of the perf assertions on faith — wait for the numbers.