Types Are Comprehensions
Koru already has a construct that folds a stream of values into a single
cell. capture seeds a value, pulses an ! each over a source, and reads
the settled result out the far side:
~capture { total: 0 }
! as acc |> for(items)
! each i |> captured { total: acc.total + i }
| captured final |> result(sum: final.total) It’s a fold, and it dissolves — the cell becomes a plain var, the field
writes become assignments, and nothing about the abstraction survives into
the binary. The question this branch started from: what if the cell weren’t a scalar? What if the thing you grew across the traversal were a list, or a
struct — or a type?
That is the constructor. Same sandwich shape as capture, but the terminal
decides what kind of thing gets built. And the surprise, once it’s built, is
how little is actually new: a type is a comprehension in exactly the sense
a list is — you traverse a source, you emit a spec per element, and the
terminal assembles the result. The only difference between building a value
and building a type is which terminal you land in.
The value terminal: a list is a fold that keeps everything
Start where capture ends. Where capture collapses the stream to one cell,
the constructor’s value terminal keeps each element — push is the collect
verb, | constructed is the after-read:
~std/constructor(nums)
! construct |> for(1..4)
! each x |> std/constructor:push(x)
| constructed built |> for(built)
! each n |> std/io:print.ln("n={{ n:d }}") This prints n=1, n=2, n=3. But the list it builds is entirely a
comptime artifact — the ! construct half lowers to a comptime accumulator
(var __acc: []const E = &.{}; __acc = __acc ++ .{x}) that runs during
compilation and leaves a fixed []const usize behind. There is no allocator,
no growth, no runtime collection. Runtime collection isn’t the constructor’s
job — that’s a store. The constructor is the part that happens before the
program runs and then gets out of the way.
The struct terminal: the same shape builds a type
Now change the terminal. Instead of collecting values, collect field
specs, and land in the struct terminal:
~std/constructor:struct(Vec3)
! construct |> for(0..3)
! each i |> std/constructor:field(name: "v{i}", type: f64) This is a comprehension whose output is a type. It walks 0..3, projects
a field name v{i} per step, and the terminal assembles a struct — Vec3 with fields v0, v1, v2, all f64. Used from the rest of the program
exactly as if you’d hand-written the declaration:
const p = Vec3{ .v0 = 1.0, .v1 = 2.0, .v2 = 3.0 };
// prints (1.0, 2.0, 3.0) Under the hood, round one lowers this to Zig’s own @Type builtin —
the constructor grows a StructField array over the range and hands it to @Type at comptime. That is the same builtin capture already leans on to
give its cell an explicit runtime type; here it’s driven by data — the
field specs the traversal produced — instead of by a seed’s typeInfo. The
whole flow dissolves into a single top-level const Vec3 = … declaration.
Consuming a type: reflection is just another source
Generation walks a range. Consumption walks an existing type’s fields — you derive a new type from one that already exists:
~std/types:struct(Player) {
name: []const u8,
health: i64,
}
~std/constructor:struct(PlayerSnapshot)
! construct |> std/types:fields-of(Player)
! each f |> std/constructor:field(f.name, f.type) PlayerSnapshot comes out with Player’s exact fields — { .name = "Alice", .health = 100 } — because the traversal read them back off Player and the struct terminal rebuilt them.
The piece worth dwelling on is std/types:fields-of(Player). It is not a
function returning an iterator. It is a traversal source — it pulses ! each f over Player’s reflected fields (each f a field with .name and .type) exactly the way for pulses ! each over a range. That is the
whole point, and it’s the shape Koru keeps coming back to: iteration is emergent. There is no iterator object, no loop that consumes it. There is a
source that fires, and handlers that catch. for is one source; fields-of is another; the sandwich in the middle doesn’t know or care which.
Because it’s a genuine source and not a special form the constructor
special-cases, fields-of also stands on its own — outside any constructor,
driving whatever handler you point it at:
~std/types:fields-of(Player)
! each f |> std/io:print.ln("field: {{ f.name:s }}")
// field: name
// field: health This lowers to an inline for over the type’s fields (they’re
heterogeneous, so it must comptime-unroll), and the effect-branch machinery
carries the reflected field to the handler with no special handling at all.
The same surface, two lowerings: inside a constructor it’s consumed to build
a type; standalone it drives iteration directly.
Two smaller things that had to exist first
Building the above surfaced two capabilities that are worth naming on their own, because they’re general.
Calling a module by its path. std/constructor(nums) isn’t a special
case — it’s the general rule that ~std/M(args) means ~std/M:default(args),
dispatching to a module’s default event. A module gets to say “what am I
when you call me,” the way a default export does. It fires only when a bare
module path names something you actually imported; everywhere else it’s a
plain unknown-event error.
Placing a transform where it belongs in the pipeline. The constructor is
a comptime transform that grafts code — including a ~for over the list it
just built. That graft has to be template-processed like any other loop,
which means the constructor has to dissolve before the template pass runs.
Koru’s transform runner already had ordered stages; what was missing was the
ability to place a named stage at a chosen point in the compiler pipeline
rather than at one fixed spot. Now a .pre stage can run ahead of template
processing, so a transform’s grafted output flows through the rest of the
pipeline as ordinary code. The compiler’s own pipeline is written in Koru, so
this was a few lines in compiler.kz — not surgery.
Where this sits
Everything here lowers to Zig’s @Type and @typeInfo for round one. That
is deliberate, and it is a crutch: Zig is the type registry today, and the
constructor is leaning on it to prove the shape works — that a value, a
struct, and a reflected type are all the same comprehension with different
terminals. The destination is a Koru-level type registry: reasoning about
type shape at the language’s own analysis stage, before Zig ever sees it,
so the constructor is generating and consuming Koru’s understanding of a type
rather than borrowing Zig’s. fields-of is the first taste of that — today a
reflection primitive, eventually one source among many (variants-of, methods-of, filtered fields) into a type system that reasons about itself.
The thesis holds in miniature: construction — of values and of types — is one comptime traversal that leaves no runtime trace. A type is a comprehension. You just have to land in the terminal that says so.