Control Flow Is a Template, Not a Compiler Node
Here is the entire implementation of for in Koru today:
~[keyword]pub event for { iterable: []const u8 }
! each *
| ?done
~proc for|template|zig {
for ({{ iterable }}) |__koru_item| {
{% for h in effects["each"] %}{{ h.inlined_link[scope] }}(__koru_item);
{% endfor %}
}
{{ continuations["done"].continue }}
} Six lines of body. No dedicated AST node, no special emitter path, no compiler pass that knows what a loop is. The conventional way to build a for loop into a compiler runs to hundreds of lines of dedicated machinery — a node type, a pass to construct it, an emitter case to lower it; ours is a template that renders to ordinary Zig, and it’s more capable for it.
This post is about the metaprogramming pattern that makes that possible, and why it’s the one we’re standardizing on: control flow is a userland template over the effect-branch engine — not a special node the compiler knows about.
Why not a dedicated node
The obvious way to add a for loop to a compiler is a dedicated AST node — a ForeachNode variant in the union, representing “iteration,” built by a pass that rewrites ~for(&items) | each … | done … into it. That’s the design Koru is not using, and it’s worth being precise about why, because the reason generalizes to every control-flow shape you’ll ever want.
A dedicated AST node is a tax you pay forever:
- The emitter needs a case for it.
switch (node) { .foreach => emitForeachLoop(...), ... }. Every node type the AST can hold is a branch the emitter must handle. - Every pass needs a case for it. The obligation checker, the purity analyzer, the serializer — anything that walks the AST has to know what a
ForeachNodeis and what to do with it. - And it multiplies by language. The day you want a JavaScript backend, that’s not
emitForeachLoop— it’semitForeachLoopAsZigandemitForeachLoopAsJs. Every node × every target. The cost of a special node isO(nodes × passes × languages).
That’s the hidden price of “the compiler knows about iteration.” Iteration is one of dozens of control-flow shapes you’ll eventually want — while, repeat, parallel-for, a SIMD loop, an iterator over someone’s tree. If each one is a node, you are signing up to extend every pass and every backend, forever.
The new way: it’s just a template
Koru already had two things that, put together, make the node unnecessary.
The first is effect branches. An event can declare outcomes that fire zero-to-many times during its execution, written with !:
~pub event for { iterable: []const u8 }
! each * // fires once per element — an effect, not a terminal
| ?done // fires once at completion — optional ! each is not “the success case.” It’s an outcome that happens N times. The producer fires it per element; the consumer handles it. (We wrote about why effect branches aren’t yield and why they have return values — they’re the spine of this whole thing.)
The second is the template-proc. A ~proc name|template|zig is a proc whose body is a template, rendered per call site and inlined as literal host code. The loop you saw at the top is the template: a real Zig for, with a hole in the middle.
The hole is the interesting part:
{% for h in effects["each"] %}{{ h.inlined_link[scope] }}(__koru_item);
{% endfor %} effects["each"] is the invoking flow’s ! each handlers, exposed to the template. The template context splits the invoking handlers by kind: effects — outcomes like ! each that fire many times during the loop — live under effects[...], while terminal continuations like | done, which fire once after, live under continuations[...]. The two are a complete pair: an outcome fires during or after, there is no third kind, so the two buckets are the whole surface. Here we loop over the effect handlers and emit {{ h.inlined_link[scope] }}(__koru_item) for each — the consumer’s loop body, dropped into the producer’s loop, bound to the iteration variable. (The [scope] marks the obligation boundary; more on that below.)
No node. No transform. No emitter case. The template renders to ordinary Zig, and the ordinary Zig emitter — the one that was always there — emits it.
link vs inlined_link: the call you can see through
Why inlined_link and not link? Because the difference is the whole reason a loop body is useful.
A ! each handler can be lowered two ways, and the template picks by which symbol it uses:
{{ h.link }}(x)emits a real call to a generated handler function. Clean, isolated — but a function can’t see the locals of the function it’s called from.{{ h.inlined_link }}(x)splices the handler’s body inline, in the caller’s scope, with the argument bound to the handler’s binding.
That second form is what makes this work, because loop bodies reach outward constantly:
~get_params()
| result p |> for(0..p.count)
! each i |> process_item(value: p.items[i])
| done |> std.io:println(text: "All items processed") The loop body reads p.items[i] — i from the iteration, p from the enclosing continuation. A handler function couldn’t see p (Zig has no closures over locals). A spliced body sits right there in scope and reads it for free. It lowers to exactly what you’d write by hand:
const p = result_0.result;
for (0..p.count) |__koru_item| {
{ const i = __koru_item; process_item(.{ .value = p.items[i] }); }
} Zero overhead. The loop body is in the loop. The scope is the scope. That access is the value proposition — and the splice is how we keep it while still making for a library, not a language feature.
Crucially, link vs inlined_link is a template-author choice, not a special case in the compiler. The compiler doesn’t know for wants inlining; it knows how to resolve an inlined_link symbol wherever a template puts one. while can use it. A parallel loop can use it. It’s a primitive, not a carve-out.
Scope, at the splice
Resource safety rides on the splice too. A loop that opens a file each iteration has to close it each iteration; a loop that holds one open across all iterations has to release it once, at the end. Koru tracks that as an obligation, and the boundary is marked exactly where the template creates it — on the splice symbol:
{{ h.inlined_link[scope] }}(__koru_item); That [scope] says “the body I’m splicing here is a per-iteration obligation boundary.” It’s a property of how the template lowers this handler, not of the for event — which is why it lives on the splice and not the declaration. One template can splice some bodies scoped and others not, deciding per-handler where the boundary falls. On render, [scope] propagates @scope onto the matching effect handler, and the obligation checker takes it from there:
~for(0..3)
! each _ |> app.fs:open(path: "log.txt")
| opened _ |> _ // the compiler auto-inserts close() here, every iteration Open a file each iteration and the checker sees the boundary and inserts the matching close per iteration. Hold a file open across the loop instead, and it refuses to release it inside — it carries the obligation through to | done, where it belongs. The template marks the boundary; the existing obligation machinery enforces it.
(One honest edge, because we don’t hide these: obligations suspended across more than one nested scope boundary — a loop inside a loop, each holding its own resource — aren’t fully tracked yet. Single-level scope is solid and tested; nested-scope suspension is the next deliberate piece. It’s a gap in the obligation checker’s depth, not in the pattern.)
The other half: the continuation
Calling effects is only half the symmetry. An effect fires during, so the template calls it. A continuation is the outcome, so the template continues to it. for’s | done runs once when the loop is finished — which is exactly what a producer handing off to its terminal branch means:
~proc for|template|zig {
for ({{ iterable }}) |__koru_item| {
{% for h in effects["each"] %}{{ h.inlined_link[scope] }}(__koru_item);{% endfor %}
}
{{ continuations["done"].continue }}
} {{ continuations["done"].continue }} is the continuation half of {{ h.inlined_link }}. Where the effect form calls a handler during the loop, the continue form hands off to the terminal, and the consumer’s | done |> body runs — the same producer→consumer cycle every branched event in the language already uses. (We name it .continue, not .return: this is continuation-passing, and the Zig lowering happening to be a return is a backend detail the surface shouldn’t leak — in a JS backend it might be a callback instead.) The compiler appends nothing; the template says where the outcome is. An omitted optional branch (a for with no | done) mints nothing and emits nothing.
So the template surface has exactly two shapes, because outcomes have exactly two kinds:
| in the template | branch kind | fires | how |
|---|---|---|---|
effects["each"] → inlined_link | effect (! each) | during | called |
continuations["done"].continue | terminal (\| done) | after | continued to |
if is the same machine
Here is the proof that this is one mechanism and not two: if. A loop has a “during” (the body, fired per element) and an “after” (the join). An if has no during at all — it’s pure outcome. So its template calls nothing; it just returns one of its terminals, picked by its own structure:
~[keyword]pub event if { cond: []const u8 }
| ?then
| ?else
~proc if|template|zig {
if ({{ cond }}) {
{{ continuations["then"].continue }}
} else {
{{ continuations["else"].continue }}
}
} That is the entire implementation of if. The template’s own if/else decides which terminal it continues to in which arm; the consumer’s | then |> / | else |> body lands in the matching arm. So:
~if(ready)
| then |> launch()
| else |> wait() lowers to exactly what you’d write by hand:
if (ready) { { launch(); } } else { { wait(); } } for and if are now the same machine — effects called during, a terminal continued to at the end — with for’s “during” full and if’s empty. Neither is a node the compiler knows about; both are six-line libraries.
(The honest edge, because we don’t hide these either: an if whose branches must discharge a resource opened before it — close-it-in-both-arms — isn’t wired through the continuation path yet. The plain conditional is solid and tested; per-branch obligation discharge inside if is the next deliberate piece, the same auto-discharge depth as nested-scope above. The dispatch is done; the obligation threading through it is the frontier.)
What this buys, beyond for and if
Trading a dedicated node for six lines of template would be a wash if it only bought for. It doesn’t — if already came for free on the same mechanism, and the pattern generalizes along two axes at once.
Other control flow is just other templates. if is the first proof; the rest follow the same shape. A parallel for — call it peach, parallel-each — is the same effect-branch shape (! each over an iterable, optional | done join) with a different scheduling strategy emitted by a different template. The consumer’s loop body doesn’t change; the keyword picks how the body runs. while, repeat, custom iterators — none of them are compiler work. They’re templates. We’re not extending the compiler to add control flow anymore; we’re writing libraries.
Other backends are just other templates, too. This is where having no node pays its largest dividend. With a node, a JS backend means teaching the emitter to lower a ForeachNode to JavaScript — a new case, for every node. With templates, a JS backend means writing ~proc for|template|js with JavaScript loop syntax, selected by --lang=js. The variant system already renders every language variant and picks the matching one; the structure layer is entirely language-agnostic. The per-language knowledge lives in template-space, where it costs O(languages) instead of O(nodes × languages). The same Koru program, two targets, one set of semantics — and the backend is “a leaf emitter plus some templates,” not “a second compiler.”
That’s the shape of the whole bet: the compiler stays small and general; the language grows in userland.
The receipts
for and if are the same template machine: for works at top-level, nested, and in-pipeline (one shared emission path, no “only works at top level”) and preserves outer-scope access through the splice; if is six lines of pure continuation. Both lower to the Zig you’d write by hand, at zero overhead. Two frontiers are named and open, both in the obligation checker’s depth rather than in the pattern: nested-scope suspension, and per-branch obligation discharge inside if.
That’s the trade: less compiler, more capability, and a control-flow story that’s a library instead of a language. The loop isn’t a thing the compiler knows about. It’s a thing you can write.