Variables Don't Exist in Koru — Neither Do Constants

· 9 min read AI Authored
language designkoruvariableslvaluesrust

Koru doesn’t have variables. It doesn’t have constants either — not in the traditional sense. There is no assignment in the grammar: no x = expr statement position, no declaration form, no slot where a name denotes a location you can write to. const exists, but it is a user-space library tor — a [keyword|declaration] template in koru_std that splices const { name: value } fields verbatim into host scope — and even it can’t spell assignment, because there is no assignment to spell.

A name in a Koru flow is born attached to a delivery — a call’s output binding (call: n), an outcome arm (| branch n), an iteration element (! each i), a produce stage ({ double: i * 2 }), a tor’s parameters. There is no free-standing let, no way for a name to denote something that wasn’t just handed to it, and no way to hand it something else afterward.

The live range is the syntax tree

Here is the claim that does the work: a name’s entire possible consumer set is the subtree hanging below its birth. You can see who can touch a name by reading the indentation.

std/string-map:new()
| map m |> capture { words: 0[i64] }
    ! as acc |> std/fs:read-lines(path: "…")
        ! line l |> std/regex:scan(input: l)
            ! `[a-z]+` w |> std/string-map:get(m, k: w)
                | value c |> std/string-map:set(m, k: w, v: c + 1) |> captured { words: acc.words + 1 }
                | missing |> std/string-map:set(m, k: w, v: 1) |> captured { words: acc.words + 1 }
        | done _ |> _
        | failed e |> std/io:print.ln("FAILED {{ e:s }}")
    | captured t |> std/io:print.ln("words: {{ t.words:d }}")

m is born in the | map m arm and consumed many steps later, across nested continuations — so names aren’t ephemeral. But there is no ambient scope, no sibling code that could mention m while sitting outside its range, no control-flow edge that exposes it elsewhere. “What does m hold here” is a question the text answers directly; nothing needs to simulate the program to find out.

And this isn’t a convention — it’s enforced. A store watch body is transplanted to the store’s write sites, and if it reaches for the enclosing flow’s runtime state the compiler refuses: the arm cannot see ambient context because ambient context does not exist.

Values cross boundaries; names don’t

A name dies with its subtree — but a value can outlive any number of subtrees. It just has to change hands under a declared name at every crossing. When a tor delivers, the caller’s arm mints a fresh binding whose live range is again a subtree:

peer.matched(id: 1, index: 7): a1 |> tally.count(ack: a1, from: 1)

a1 is born at peer.matched’s delivery and handed across the seam as ack — the callee’s parameter is a new birth in the callee’s subtree. The value crosses; the location doesn’t. Even a @label loop works this way: a jump doesn’t store into a cell, it re-seeds the target’s captures — rebinding per hop, not mutation of a cell.

The = exists — once, as a definition

The glyph survives in exactly one position: name = <flow body> — a definition that binds a name to a computation, once, the way mathematics uses the symbol:

tally.count = std/store:stored { raft.counted: raft.counted + 1, raft.best: ack }
    |> std/io:print.ln("  counted ack from peer {{ from:d }} at index {{ ack:d }}")

That = names a flow’s implementation — a callable, not a cell. It cannot be written again because there is no statement position where a second = could live. And const, the one keyword that looks like a declaration, is the same story told in library code:

const {
    name: "World"
    debug: true
    count: 42[i32]
}

That’s the front page’s own example — a braced declaration list, not an assignment form. The fields are spliced verbatim into host scope by a template tor; the = in const name = value exists only in the emitted Zig, never in Koru’s grammar. Spell it C-style — const SIEVE_BITS: usize = 500000; — and it refuses at parse.

The variable that isn’t

The one thing that looks like a variable is capture, and the lookalike is worth staring at:

captured { words: acc.words + 1 }

That reads exactly like words = words + 1. It isn’t — but it isn’t secretly a fold either. acc is the live record for one scoped transaction: bound at the ! as arm, dead at its end. captured {} is an immediate field-wise write to that record — it can appear anywhere inside the arm, chained, conditional, nested — and everything after it in the arm sees the write. There is no staging and no atomic flush: the arm’s end is simply where the record’s scope ends, and | captured is the one-shot after-read into the caller’s subtree.

And that is the whole essay in miniature. The semantics are defined by the flow — the record is reachable only inside the as arm’s subtree — but the implementation never is. Immediate field writes today, a staged commit tomorrow: the compiler can do whatever it wants with the cell because its entire consumer set is enumerable from the text. Static analysis this trivial is what buys the freedom — the moment a name’s readers are a subtree, “what the variable really is” stops being a language question and becomes a compiler detail. The emitted Zig is a real var with field writes, because that’s what was cheapest to emit. What the grammar withholds isn’t change; it’s the ambient name for a place.

The same shape recurs wherever Koru mutates — and where the boundary is a real transaction, that’s implicit in the module’s shape too. std/cell’s durable cells use the identical grammar — ! as live record, captured writes, | captured after-read — but the arm’s end there is a commit: every declared field flushes to the cell’s durable state in one seam, whether or not anyone reads it:

| cell c |> std/cell:capture(c, shape: room_state)
    ! as s |> std/io:print.ln("seeded visits={{ s.visits:d }}")
      |> std/cell:captured { visits: s.visits + 1 }
      |> std/cell:captured { name: "assistant-01", awake: true }
    | captured f |> std/io:print.ln("visits={{ f.visits:d }}")

You never spell “commit” — the arm boundary is the commit. Same spellings, stronger semantics, and nothing at the call site marks the difference, because the scope discipline was already the contract. capture is scope-only mutation, std/cell is transactional mutation, std/store’s stored {} is addressed mutation — one shape, three strengths, and no ambient write anywhere in the set.

Mutation exists — addressed, never ambient

Koru programs do write things. They write them through addresses that are declared, not names that happen to be writable:

| then |> std/store:stored { raft.commit: upto } => advanced

raft.commit isn’t a variable — it’s a path into a store whose columns were declared at std/store:new. A kernel body is the same shape one layer down: std/kernel:pairwise { k.mass += k.other.mass } writes through the kernel’s view into the data, inside a source block that is itself emitted code. var survives in exactly those places — |zig proc bodies, kernel bodies, comptime procs — as local scratch below the semantic floor, never as flow state.

What the gap costs everyone else

So what does a language buy by having x =? The honest answer is: a gap. let x = f() puts x in a block-scope soup, and everything between the let and the last use is territory where any code, in any order, can observe or write the cell. Rust’s let does four things silently — introduces a name, seizes or copies custody, maybe makes a mutable cell, opens the ambient gap — and the language then spends its most famous machinery reconstructing what that primitive obscured:

  • let b = a is a copy or a seizure depending on whether the type implements Copy — a trait you can’t see from the site. The custody ledger exists, but only in the borrow checker’s head.
  • An Err can be discarded five different ways — if let Ok, a wildcard _ => {} arm, let Ok else, .ok(), unwrap_or — each satisfying #[must_use], none greppable. rg 'unwrap' finds every panic site anyone ever wrote and misses every silent drop; the obligation is discharged by observation, not handling.
  • The borrow checker still can’t see through a &mut self method boundary (disjoint fields fine inside a body, rejected through methods — no view types), and NLL still rejects the Polonius working group’s own case 3 — get_or_insert refused because a borrow returned down one path is treated as live on paths where it was never returned.

NLL, the drop checker, and the optimizer each reconstruct — separately, approximately — the forward dataflow that Koru just is.

Rust is amputating its own lvalue surface

The cleanest evidence isn’t our corpus; it’s Rust’s own changelog. static mut is Rust’s one ambient mutable lvalue — a cell every function in the program can see, every thread, code not yet written. In edition 2021, let r = &X inside unsafe compiles with a warning and prints 0. In edition 2024, the same file is a hard error via static_mut_refs — and the suggested fix is &raw const X: exit the reference system entirely.

Verified on this machine, rustc 1.96.0-nightly, both editions, same file. The language agrees the ambient writable name is unsound — it just can’t finish the surgery, because let is the only shape it has. let mut is load-bearing; the amputation stops where the primitive starts.

Koru never grew the surface being amputated. const — the one ambient-name construct — is ambient for reads and nothing else: nothing behind the name can be written, owed, or addressed, which is exactly why ambient read is safe. Rust is learning, edition by edition, that the honest version of static mut is no static mut. Koru started there.

The inversion

Here is why any of this matters past aesthetics. A name that can only be born at a delivery, and can only be consumed inside its subtree, means the program states its own dataflow. A fold whose every arm jumps back is refused at compile time as provably non-exiting — because every edge in the loop is declared. A module can enumerate its own seams — std/store’s entire public surface is thirteen declared tors, and its write vocabulary is exactly insert, take, stored, clear — which is impossible in a language where any pointer write anywhere could be an effect.

This is the second pillar of why Koru is easy to analyze statically, next to control flow living in the type system. Types carry the control flow; the syntax tree carries the dataflow. Concretely: def-use chains are a tree walk, because a binding’s entire consumer set is the subtree under its birth — no CFG reconstruction. Liveness is birth-site to subtree end, written in the layout. Alias analysis as a global problem doesn’t exist — the only place two names could still denote one cell is a single declared invocation, where both parameters are enumerable in the text, so refusing it is a syntactic check, not an analysis. Mutation sites are a lexical query — every write is a captured or stored block with its field names on it, findable by a regex, not a pointer analysis. And a binding that is never consumed is a syntactic fact, not a warning heuristic. The compiler is not estimating any of these; it is reading them.

Every other language’s analysis tooling fights its own generality: NLL reconstructs liveness backwards through the scope soup, solvers carve decidable islands out of aliasing and arbitrary mutation, and the fence costs most of the surface. Koru was pre-restricted — the fragment and the language nearly coincide, so the fence costs almost nothing. That is the working bet underneath the verification endeavor: the lvalue isn’t a neutral feature. It’s the feature that turns every analysis into a reconstruction. Remove it, and analysis problems become walks.

What you give up

Honesty requires the other column. There is no x += 1 you can write anywhere you like. Accumulating state means capture, a store, a cell — a declared transaction with a commit boundary, every time. Sharing a mutable cell across distant code isn’t a refactor away; it’s a design decision the language makes you spell out loud. That is the tax, and it’s real — paid once per declaration, against every “what does x hold here” you will never again have to simulate.

Koru doesn’t have variables. It has names for things that were just delivered, addresses for things that can be written, and a grammar where the two are never confused. The variable was the conflation.