How ~if Works: From Dead End to Zero Overhead
How ~if Works: From Dead End to Zero Overhead
Most languages have if as a primitive. It’s in the grammar, handled by the parser, special-cased in the compiler. You can’t redefine it, extend it, or understand it without reading compiler source.
Koru’s ~if is different. It’s defined in $std/control.kz using the same mechanisms available to any user code. But getting there wasn’t straightforward. We tried one approach, hit a wall, and discovered something better.
Here’s the full story.
The Goal
We wanted to write:
~if(value > 10)
| then |> std.io:println(text: "Big number!")
| else |> std.io:println(text: "Small number") And have it work like you’d expect: evaluate value > 10, take the then branch if true, else if false.
The constraints:
- No compiler magic - use the same mechanisms available to users
- Zero overhead - as fast as writing Zig’s
ifdirectly - Composable - works inside other control flow, subflows, anywhere
First Attempt: Two Events
Our first design split ~if into two cooperating events:
// Runtime: actually does the branching
~[runtime]event if.impl { condition: bool }
| then {}
| else {}
~[runtime]proc if.impl {
if (condition) {
return .{ .then = .{} };
} else {
return .{ .@"else" = .{} };
}
}
// Compile-time: rewrites ~if(expr) to ~if.impl(condition: expr)
~[keyword|comptime|transform]pub event if { expr: Expression, ... }
| transformed { program: *const Program } The transform would rewrite ~if(value > 10) into ~if.impl(condition: value > 10). The runtime event would evaluate the condition and return the appropriate branch.
The logic made sense:
- Separation of concerns - transform handles syntax, impl handles semantics
- Testability -
if.implcan be called directly with a boolean - Familiar pattern - just AST rewriting, like macros in other languages
The Dead End
It worked. But when we looked at the generated code:
// What we generated
const result = if_impl_handler(.{ .condition = value > 10 });
switch (result) {
.then => { /* then body */ },
.@"else" => { /* else body */ },
} Every ~if became a function call. The handler had to:
- Receive the condition
- Evaluate it
- Construct a tagged union
- Return it
- Switch on the result
Compare to just writing Zig:
// What a human would write
if (value > 10) {
// then body
} else {
// else body
} The overhead was real. Not catastrophic, but measurable. More importantly, it violated our principle: Koru should generate code as good as a human would write.
The two-event approach was clever, but it was a dead end.
The Breakthrough: Templates
The insight came from asking: what if the transform didn’t rewrite to another event, but directly emitted the code we wanted?
Instead of:
~if(expr) → ~if.impl(condition: expr) → handler call → switch What about:
~if(expr) → inline Zig code We needed a way for transforms to emit arbitrary Zig code. But hardcoding string templates in transform procs would be messy and error-prone.
Solution: Make templates first-class.
Templates as Data
We created a template system where code patterns are defined declaratively:
// In $std/template.kz
~define(name: "if") {
if (CONDITION) { THEN_BODY } else { ELSE_BODY }
}
~define(name: "for") {
for (ITERABLE) |BINDING| { BINDING_DISCARD EACH_BODY }
DONE_BODY
} Templates are stored in the AST as data. Placeholders use ${...} syntax:
${name}- substitute a named value (like CONDITION above)${| branch |}- inline the generated code for a continuation branch (like THEN_BODY)
Templates aren’t code that runs. They’re patterns that transforms can look up and instantiate.
The New Architecture
Now ~if is a single transform event with no runtime component:
~[keyword|comptime|transform]pub event if {
expr: Expression,
item: *const Item,
program: *const Program,
allocator: std.mem.Allocator
}
| transformed { program: *const Program } The transform proc:
Looks up the template
const template = template_utils.lookupTemplate(program, "if"); // Returns: "if (CONDITION) { THEN_BODY } else { ELSE_BODY }"Generates code for each branch
const then_code = continuation_codegen.generateContinuationChain( allocator, then_cont, module, &counter, indent ); const else_code = continuation_codegen.generateContinuationChain( allocator, else_cont, module, &counter, indent );Interpolates the template
const bindings = [_]Binding{ .{ .name = "condition", .value = expr }, .{ .name = "then", .value = then_code }, .{ .name = "else", .value = else_code }, }; const inline_body = template_utils.interpolate(template, bindings);Sets the flow’s inline_body
const transformed_flow = ast.Flow{ // ... other fields ... .inline_body = inline_body, // THE KEY };
When the emitter sees a flow with inline_body set, it emits that code verbatim instead of generating a handler call.
Zero Overhead
The generated code is now exactly what a human would write:
// What Koru generates
if (value > 10) {
// then body - generated from continuation
} else {
// else body - generated from continuation
} No function calls. No tagged unions. No switches. Just a Zig if statement.
The overhead is literally zero. The abstraction costs nothing at runtime.
Why This Matters
The template approach solved more than just ~if:
1. ~for uses the same pattern
~define(name: "for") {
for (ITERABLE) |BINDING| { EACH_BODY }
DONE_BODY
} ~for(&[_]i32{ 1, 2, 3 })
| each x |> process(value: x)
| done |> finalize() Generates:
for (&[_]i32{ 1, 2, 3 }) |x| {
// process(value: x) code
}
// finalize() code 2. Users can define their own
The template system isn’t privileged. User code can define templates and write transforms that use them. Want ~unless? ~repeat? ~match? Same pattern.
3. Templates compose
A ~for inside a ~if works correctly. A ~if inside a subflow works correctly. The transform runs, generates code, and the code is inlined. No special cases needed.
The Code
Here’s the actual ~if transform (simplified):
~proc if {
const template_utils = @import("template_utils");
const continuation_codegen = @import("continuation_codegen");
// Look up the "if" template
const template = template_utils.lookupTemplate(program, "if")
orelse return .{ .transformed = .{ .program = program } };
// Find | then |> and | else |> continuations
var then_cont: ?*const ast.Continuation = null;
var else_cont: ?*const ast.Continuation = null;
for (flow.continuations) |*cont| {
if (std.mem.eql(u8, cont.branch, "then")) then_cont = cont;
if (std.mem.eql(u8, cont.branch, "else")) else_cont = cont;
}
// Generate code for each branch
var counter: usize = 0;
const then_code = continuation_codegen.generateContinuationChain(
allocator, then_cont.?, flow.module, &counter, 0
) catch unreachable;
const else_code = if (else_cont) |ec|
continuation_codegen.generateContinuationChain(
allocator, ec, flow.module, &counter, 0
) catch unreachable
else "";
// Interpolate template
const bindings = [_]template_utils.Binding{
.{ .name = "condition", .value = expr },
.{ .name = "then", .value = then_code },
.{ .name = "else", .value = else_code },
};
const inline_body = template_utils.interpolate(
allocator, template, &bindings
) catch unreachable;
// Create transformed flow with inline_body
const new_flow = ast.Flow{
// ... preserve existing fields ...
.inline_body = inline_body,
};
// Replace in program
// ...
} Try It
~import "$std/io"
~import "$std/control"
const value = 42;
const small = 5;
~if(value > 10)
| then |> std.io:println(text: "Greater than 10")
| else |> std.io:println(text: "10 or less")
~if(small > 10)
| then |> std.io:println(text: "Greater than 10")
| else |> std.io:println(text: "Small is 10 or less") Output:
Greater than 10
Small is 10 or less The Lesson
The two-event approach was a reasonable first attempt. It worked, it was testable, it followed patterns we’d used elsewhere. But it had overhead, and that overhead violated our core promise.
Koru should generate code as good as a human would write.
Templates gave us that. By making code patterns first-class data that transforms can look up and instantiate, we got:
- Zero runtime overhead
- Clean separation between pattern definition and pattern use
- A mechanism that users can leverage for their own control flow
The dead end taught us what we actually needed. The breakthrough came from taking templates seriously as a compiler feature.
What’s Next
The same template pattern works for any control flow:
~while(condition)with| body {} | done {}~match(value)with pattern branches~capture(init: {...})for accumulation with pure semantics
Each is a library-defined transform. Each uses templates for zero-overhead code generation.
The language grows by writing libraries, not by changing the compiler.
Koru: Where control flow is defined by templates, and abstractions cost nothing.
Updated December 2, 2025