~capture Is Something Else

· 10 min read

We wrote a post comparing ~capture to Haskell’s foldl’. The benchmark was real — Koru matches hand-written Zig, runs 4x faster than idiomatic Haskell. We stand by those numbers.

But the comparison framed ~capture as a better fold. That’s wrong. A fold is one pattern: one accumulator, one combining function, one pass. ~capture is something larger than that, and the fold framing undersells it.

Let’s say what it actually is.

Start Simple

The basic form:

~capture({ sum: @as(i64, 0), count: @as(i32, 0) })
| as acc |> for(&[_]i32{1, 3, 6, 8, 10})
    | each item |> if(item > 5)
        | then |> captured { sum: acc.sum + @as(i64, item), count: acc.count + 1 }
        | else |> captured { sum: acc.sum, count: acc.count }
| captured result |> std.io:print.ln("{{ @divTrunc(result.sum, @as(i64, result.count)):d }}")

You declare an initial state. You name your accumulator (acc). Inside the loop, you declare what the next state should be with captured { ... }. At the end, | captured result |> gives you the final value.

No mutation visible. State flows forward. The compiler generates direct var assignments — the same Zig you’d write by hand.

This looks like a fold. But notice: ~for and ~if are both inside the capture, and captured {} is inside the ~if branch. Three separate transforms — ~capture, ~for, ~if — composed, each unaware of the others. That’s already more than fold gives you.

Multiple Fields, One Pass

~capture({ sum: @as(i32, 0), max: @as(i32, 0) })
| as c |> for(&[_]i32{ 3, 1, 4, 1, 5 })
    | each val |> captured {
        sum: c.sum + val,
        max: if (val > c.max) val else c.max
    }
| captured final |> std.io:print.ln("sum={{ final.sum:d }} max={{ final.max:d }}")

Sum and max in one pass. Both fields updated simultaneously in one captured {} constructor. In Haskell this is foldl' (\(s, m) x -> (s + x, max m x)) (0, minBound) xs — readable, but you’re threading a tuple through a lambda. In Koru you name your fields and read them by name. The accumulator is a struct, not a tuple.

This is still in fold territory. Now it gets interesting.

Nested Captures

~capture({ outer: @as(i32, 0) })
| as outer |> capture({ inner: @as(i32, 0) })
    | as inner |> captured { inner: inner.inner + 42 }
    | captured result |> captured { outer: outer.outer + result.inner }
| captured final |> std.io:print.ln(final.outer:d)

Two captures, nested. The inner capture runs, produces a result, and then captured { outer: outer.outer + result.inner } updates the outer capture from inside the inner one’s | captured |> branch.

Each capture has its own binding name (outer, inner). They can’t shadow each other — that’s not a restriction, that’s the design decision that makes the whole thing work. Because there’s no shadowing, the transform always knows unambiguously which captured {} block belongs to which accumulator.

There is no fold equivalent for this. You can’t nest foldl' inside foldl' and have the inner result feed the outer accumulator in a single expression. You’d need state monad transformers. In Koru you just… nest the captures.

Binding-Qualified Updates

When fields in nested captures share names, you qualify:

~capture({ count: @as(i32, 0) })
| as outer |> capture({ count: @as(i32, 0) })
    | as inner |> captured { inner.count: inner.count + 1 }
    | captured r |> captured { outer.count: outer.count + r.count }
| captured final |> std.io:print.ln(final.count:d)

inner.count targets the inner capture. outer.count targets the outer. A single captured {} block can update both simultaneously:

captured { outer.total: outer.total + r.sum, inner.count: inner.count + 1 }

One constructor. Two captures. Both updated. The transform sorts it out at compile time.

Array Indexing on the Left

~capture({ arr: [3]i32{ 0, 0, 0 } })
| as acc |> for(0..3)
    | each i |> captured { arr[i]: acc.arr[i] + @as(i32, @intCast(i)) * 10 }
| captured result |> std.io:print.ln("{{ result.arr[0]:d }} {{ result.arr[1]:d }} {{ result.arr[2]:d }}")

arr[i] on the left-hand side of a captured {} field. The index i is a live runtime value from the enclosing ~for. The transform generates a direct indexed assignment into the captured array — acc.arr[i] = value — at compile time, from a declaration that looks purely functional.

This is where the n-body simulation becomes expressible:

|> capture({ dv: ZERO_DV })
    | as acc |> for(0..5)
        | each i |> for(i+1..5)
            | each j |> pair_force(bi: b[i], bj: b[j], dt: DT)
                | result f |> captured {
                    dv[i][0]: acc.dv[i][0] - f.fx*f.mj,
                    dv[i][1]: acc.dv[i][1] - f.fy*f.mj,
                    dv[i][2]: acc.dv[i][2] - f.fz*f.mj,
                    dv[j][0]: acc.dv[j][0] + f.fx*f.mi,
                    dv[j][1]: acc.dv[j][1] + f.fy*f.mi,
                    dv[j][2]: acc.dv[j][2] + f.fz*f.mi
                }
    | captured deltas |> apply(bodies: b[0..], dt: DT, dv: deltas.dv)

A 5×5 velocity delta accumulation across a triple nested loop. Six array indices updated in one captured {} constructor, where both i and j are live loop variables from enclosing ~for branches. The result is handed to apply which advances all body positions.

This compiles to direct array mutations. No intermediate allocations. No copies. The same Zig a systems programmer would write by hand — but expressed as forward-flowing declarations.

Capture From Existing State

const entity = Entity{ .id = 42, .health = 100, .pos_x = 10 };

~capture(entity)
| as acc |> for(&[_]i32{10, 20, 30})
    | each damage |> captured { health: acc.health - damage }
| captured final |> std.io:print.ln(final.health:d)

No { } literal in the capture — you’re binding to an existing struct. captured { health: ... } updates just that field; the others (id, pos_x) are preserved unchanged. The transform knows the struct shape at compile time and generates precise field assignments.

What It Actually Is

~capture is not a fold. Here is what it is:

Structured mutable state expressed as forward-flowing declarations.

You write what the next state should be. The compiler generates the mutations. The no-shadowing rule means every captured {} field is unambiguously owned by exactly one capture, even when captures nest arbitrarily deep and a single constructor updates multiple levels simultaneously.

The properties together:

  • Multi-field — accumulate any number of values simultaneously in one struct
  • Multi-level — nest captures; inner results feed outer accumulators
  • Multi-target — one captured {} constructor updates multiple captures at once
  • Array-indexed — left-hand side can be a runtime-indexed array element
  • Existing-struct — bind to existing state, update only what changes
  • Composable — works inside ~for, ~if, any nesting, because they’re all AST transforms that compose
  • Zero-cost — compiles to direct var mutations, no overhead

No existing construct does all of this. Haskell’s foldl' does one accumulator, one pass. The State monad handles nested state but with runtime overhead and ceremony. Rust’s fold is expressive for iterators but doesn’t compose with conditionals and doesn’t support multi-target updates. C’s mutable loops do the mutations but give you no structure.

It Lives In User Space

The most important thing: ~capture is a standard library transform. It’s not a language feature. It’s an event in control.kz, implemented as a comptime rewrite rule in about 400 lines of Koru, using the same tools available to any library author.

The same tools that power ~orisha:router. The same tools that power ~if and ~for. Anyone can write a construct like ~capture. We did — and we discovered what it could do as we built it.

We didn’t find this in the literature. It emerged from the model: what does accumulation look like when you take event continuations seriously, enforce no-shadowing, and let the compiler handle the mutation?

It looks like this. And we don’t know of another language where you can write it.


See also: ~capture vs foldl’: When Pure Abstractions Match Raw Performance for the benchmark results. In Koru, Coding Is Compiler Coding for the broader picture of user-space language extension.