How ~if Works: From Dead End to Zero Overhead

· 12 min read

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 if directly
  • 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.impl can 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:

  1. Receive the condition
  2. Evaluate it
  3. Construct a tagged union
  4. Return it
  5. 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:

  1. Looks up the template

    const template = template_utils.lookupTemplate(program, "if");
    // Returns: "if (CONDITION) { THEN_BODY } else { ELSE_BODY }"
  2. 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
    );
  3. 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);
  4. 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