How ~capture Works: Zero-Cost Accumulators

· 10 min read

How ~capture Works: Zero-Cost Accumulators

Every language needs a way to accumulate values. Sum a list. Track a maximum. Build up state across iterations. Most languages use mutable variables:

let total = 0;
for (const x of items) {
  total += x;
}

Koru is event-driven and pure. Events fire, branches handle them, values flow forward. So how do you accumulate?

With ~capture:

~capture(expr: { total: @as(i32, 0) })
| as c |> captured { total: c.total + item }
| captured final |> use_result(final.total)

This looks different from a traditional loop, but it’s doing the same thing: starting with total = 0, updating it with each iteration, and using the final value. The difference is that it’s expressed as pure data flow, not imperative mutation.


What ~capture Does

~capture is a comptime transform that generates inline Zig code. It:

  1. Initializes a capture struct with your starting values
  2. Exposes the current value as a binding (c in our example)
  3. Rewrites captured { ... } branches into assignments
  4. Binds the final value when done

The generated code looks like:

var c = .{ .total = 0 };
c = .{ .total = c.total + item };  // from captured { ... }
const final = c;
// ... done branch code

Zero overhead. No function calls. Just a mutable variable with pure semantics in the Koru source.


The First Bug: Parser Truncation

Our first test case was:

~capture(expr: { total: @as(i32, 0) })

The parser was seeing:

name: "expr", value: ""  // Empty!

What happened? The parser has a function withoutLabel that strips label markers like @myLabel from the end of lines. It was looking for @ and truncating there:

capture(expr: { total: @as(i32, 0) })
                      ^ TRUNCATE HERE

The @as Zig builtin was being treated as a label!

The fix: Make withoutLabel depth-aware. Only consider @ as a label marker when it’s:

  • At depth 0 (not inside parens/braces/brackets)
  • Preceded by a space (label syntax is event @label, not @as(...))

The Second Bug: Zig Code Detection

After fixing truncation, we hit:

error: Zig code not allowed in flows. Flows are pure plumbing.

Koru has a check to prevent users from accidentally writing Zig code in flows. It looks for patterns like @import, @as, std.debug.print. Our invocation triggered it:

~capture(expr: { total: @as(i32, 0) })
                        ^^^ FLAGGED AS ZIG CODE

But @as inside an Expression argument is perfectly valid! The user is providing a typed initial value.

The fix: Only check for Zig patterns in the event name, not in the arguments:

// Before: checked entire invocation
if (std.mem.indexOf(u8, content, "@as") != null) return true;

// After: only check before the opening paren
const check_range = content[0..paren_idx];
if (std.mem.indexOf(u8, check_range, "@as") != null) return true;

This allows ~capture(expr: { total: @as(i32, 0) }) while still blocking ~@as(i32, 0).


The Third Bug: Comptime Struct Types

Now the parser worked. The generated code was:

var c = .{ .total = @as(i32, 0) };
c = .{ .total = 42 };  // ERROR!

Zig error:

error: value stored in comptime field does not match the default value

What? The problem is Zig’s type inference for anonymous struct literals. When you write:

var c = .{ .total = @as(i32, 0) };

Zig infers an anonymous struct type where .total is a comptime field because its value is known at compile time. Comptime fields can’t be reassigned at runtime.

We need a runtime struct. If we had an explicit type, it would work:

const T = struct { total: i32 };
var c: T = .{ .total = 0 };
c = .{ .total = 42 };  // Works!

But we don’t have an explicit type. The user just wrote { total: @as(i32, 0) }.


The Solution: Comptime Metaprogramming

Zig has powerful comptime capabilities. We can transform a struct type at compile time, converting comptime fields to runtime fields:

const __CaptureT = comptime blk: {
    const info = @typeInfo(@TypeOf(.{ .total = @as(i32, 0) }));
    var fields: [info.@"struct".fields.len]@import("std").builtin.Type.StructField = undefined;
    for (info.@"struct".fields, 0..) |f, i| {
        fields[i] = .{
            .name = f.name,
            .type = f.type,
            .default_value_ptr = null,  // Remove default = runtime
            .is_comptime = false,       // Force runtime
            .alignment = f.alignment,
        };
    }
    break :blk @Type(.{ .@"struct" = .{
        .layout = .auto,
        .fields = &fields,
        .decls = &.{},
        .is_tuple = false,
    }});
};

var c: __CaptureT = .{ .total = @as(i32, 0) };
c = .{ .total = 42 };  // Works!

We use @typeInfo to inspect the struct, rebuild it with is_comptime = false on all fields, and @Type to create the new type. All at compile time. Zero runtime cost.


The Template

The capture template in $std/template.kz generates this pattern. Template placeholders like $binding get filled in by the transform:

const __CaptureT = comptime blk: { ... };
var $binding: __CaptureT = $init;
$| as |
const $done_binding = $binding;
_ = &$done_binding;
$| done |

The ~capture transform in $std/control.kz rewrites captured { ... } branches into inline assignment code. So this:

captured { total: c.total + 1 }

Becomes:

c = .{ .total = c.total + 1 };

The Result

You can now write pure accumulation patterns:

~for(items)
| each item |>
    capture(expr: { sum: @as(i32, 0), max: @as(i32, 0) })
    | as acc |> captured {
        sum: acc.sum + item,
        max: if (item > acc.max) item else acc.max
    }
    | done result |> process_stats(result.sum, result.max)
| captured |> finish()

Multiple fields. Type-safe. Zero overhead. All through the same event/continuation model as everything else in Koru.


What We Learned

  1. Parser depth-awareness matters - When parsing nested syntax, simple string matching breaks. Always track depth.

  2. Validation needs context - The “is this Zig code?” check was right in spirit but wrong in scope. Arguments can contain Zig expressions; event names cannot.

  3. Zig’s type system is powerful - The comptime metaprogramming that converts struct types is exactly what we needed. No runtime cost, full type safety.

  4. Templates compose - ~capture is built on the same template system as ~if and ~for. User code can define new control flow patterns using the same mechanisms.

The capture feature took three bug fixes to get working. Each one revealed something about how parsing, validation, and code generation interact. The result is a clean primitive that makes accumulation as natural as any other Koru pattern.