Koru Can Now Emit JavaScript
Compile-time templates crossed over tonight. Hello world runs on both targets. Here's what we measured.
This is experimental work. The Koru compiler is experimental. The JavaScript backend is more experimental still — about two days old. What follows is a real result with real numbers, but it’s also one passing test and a benchmark we wrote ourselves. We’re going to speculate in a few places about where this could go. Where we speculate, we say so.
The Koru compiler grew a JavaScript backend over the weekend. Tonight it
learned to evaluate Koru’s compile-time templating system — the same one
that powers most of the standard library’s I/O, formatting, and a long tail
of stdlib infrastructure on the Zig target. Until now, those [comptime|transform] procs only knew how to emit Zig. Tonight, one of them learned to emit
JavaScript.
Most people reading this have never seen Koru, so let’s start with the input. The whole program lives in one file:
// hello.kjs
~import "$std/io"
const name = "World";
const debug = true;
const count = 42;
~std.io:print.blk {
{% if debug %}[DEBUG] {% endif %}Hello, {{ name:s }}!
The answer is {{ count:d }}.
} See this hello world in the lesson tour — it’s the regression test that’s been the canonical first example since the start.
A quick orientation if you’ve never seen Koru: ~ is a parser-mode switch
into Koru, ~import "$std/io" brings in the standard library’s I/O
module, the three const declarations are plain JavaScript sitting in
scope (the file extension is .kjs — the host language for this file is
JavaScript), and ~std.io:print.blk { ... } is a call whose body is a
Liquid template — {% if %} for conditionals, {{ var:fmt }} for
interpolations.
The same program written as hello.kz instead — same Koru constructs,
the three consts in Zig syntax — would compile to native code via Zig.
That’s the cross-target story; One AST, two backends below covers how
the same source lowers to either host language. For now, just one file
and the JavaScript path.
Pass --lang=js to the compiler and hello.kjs produces:
const name = "World";
const debug = true;
const count = 42;
const main_module = {
flow0() {
console.log((debug ? "[DEBUG] " : "") + "Hello, " + name + "!\nThe answer is " + count + ".");
},
};
main_module.flow0(); There is no template engine in that file. There is no transform helper
imported from a runtime library. There is no JSX-shaped intermediate. The
Liquid template was executed by Koru’s compile-time transform pipeline and
emitted directly as JavaScript bytes — the conditional became a ternary,
the interpolations became string concatenations, the whole thing got
wrapped in a console.log and inlined into the flow. The template existed
during the build. It does not exist at runtime.
This is the same property that Zig’s comptime has, ported across the
language line. What you write at compile time, you don’t pay for at runtime
— even when the runtime is V8.
Two backends from the same source
The hello world test now runs on both targets. Same ~std.io:print.blk { ... } block, same Liquid template content, byte-identical output. On the Zig
target the block is lowered into a Zig print expression that interpolates
the same variables; on the JavaScript target it’s lowered into the console.log(... + ...) you saw above. Different host idioms, same
user-facing semantics, both produced by the same compile-time pass walking
the same Liquid bytes.
That fact has a structural consequence we think matters more than the hello world demo itself: the template content is now a language-agnostic intermediate representation. The author of a stdlib transform writes one template and one small Zig variant per target language. The variant decides how to turn the parsed template into host output — Zig’s format strings, JavaScript’s string concatenation, whatever the host wants. The template content is reusable across targets the way bytecode is reusable across architectures: it captures intent, not implementation.
For the standard library, this changes the porting story. Adding JavaScript
support to a [comptime|transform] stdlib item — print.ln, eprintln, format.write, and a long tail — is now writing a variant that emits a
different host language at the end of the same parse. It is not rewriting
the stdlib in JavaScript. That’s a different complexity curve.
One AST, two backends
The architecture that makes the cross-target compile possible is worth naming, because the way both backends coexist in one compilation is doing real work.
The simple case is the one above: a single .kjs file mixes Koru
constructs and JavaScript host bytes, and koruc --lang=js compiles it
to JavaScript. A .kz file does the same for the Zig target. Each file
is the host extension of its target.
When you want both targets from the same source, you can split into a stem — a small set of co-located files that get merged into a single
AST. A .k file (the contract) carries the language-agnostic Koru
constructs; .kz carries Zig-side host bytes; .kjs carries
JavaScript-side host bytes; .kc would carry C-side host bytes. All of
them get parsed into one program, with each piece of host code tagged by
which file — and therefore which host language — it came from.
The Koru parser does not try to make Zig sense of the Zig host bytes, or
JavaScript sense of the JavaScript host bytes. They’re opaque to it,
captured as host_line AST nodes carrying their source-file location. The
compiler knows they’re host bytes; it does not know or care what host
language they’re in. Koru constructs themselves — events, flows, comptime
transforms, phantom states, everything the language defines — sit alongside
the host bytes in the same AST, target-agnostic.
When the emitter runs, two compile-time selections happen:
- Host-line routing. Each
host_lineis emitted into the output for the matching target. On the Zig path,.kz-sourced bytes go in and.kjs-sourced bytes are skipped. On the JavaScript path, the opposite. Synthesized bytes from the compiler’s own infrastructure (no source file) emit on both paths because they’re host-agnostic. - Transform variant selection. A
[comptime|transform]proc that declares multiple target variants gets dispatched to the variant whose base tag matches--lang.print.blk|jsfires under--lang=js. The bareprint.blk|zigis the default under--lang=zig. The whole variant system is honestly under-specified right now and we expect to revisit it — that’s separate from the point this section is making, which is that target selection happens once, at compile time, with no runtime helper involved.
Both decisions are settled before any code runs. There is no language detection at runtime, no target-selection helper to import, no engine. By the time the output file is written, the program has been resolved against exactly one target.
The point of doing it this way is not portability for its own sake. It’s
that having two backends sharing one middle is a forcing function on the
middle. The host-line routing bug we caught at the start of this work
— a leak where the Zig emitter was emitting .kjs host bytes into Zig
output — had been sitting silently in the codebase, invisible because
nothing exercised the boundary. Adding a second target made the latent
contract explicit; the bug surfaced; the fix tightened the emitter for
both targets. The same dynamic should keep applying as cross-target
coverage grows. Each new test that runs on both targets forces the shared
middle to be honest about what it’s actually contracting for.
This is the reason to be unambiguous about it: Zig is the primary target, and it is staying primary. The compiler is implemented in Zig. The bulk of the standard library targets Zig. The regression suite holds the Zig output to a tight contract — a contract that the cross-target work has actually strengthened, by surfacing assumptions the single-target case was quietly hiding. JavaScript is being added as a peer target, and the value of having it as a peer includes what its presence does back to the Zig path. The two backends are not in competition for which one wins; they’re collaborators on a shared middle that both depend on.
What the dispatch numbers look like
The other thing that happened this weekend was a benchmark on the synthetic dispatch shape Koru emits — a vaxis-shaped event pump (key / resize / focus_in events fanning out to handlers, the structural skeleton of a terminal app’s input loop). The handler work in each strategy is identical; what differs is how the dispatch is wired.
1. EventEmitter (idiom) 11.33 ns/event Node's events module
2. listener-map (hand) 12.95 ns/event hand-rolled { type: [fns] } registry
3. koru switch+call 5.10 ns/event static switch → direct handler call
4. koru str-switch+inline 4.19 ns/event string-switch, handler bodies inlined
5. koru int-switch+inline 4.01 ns/event int-switch, handler bodies inlined
6. koru EMITTED (yesterday) 5.41 ns/event what we emitted before the inline pass
7. koru EMITTED (today) 2.67 ns/event what we emit after the inline pass (Methodology: N = 5,000,000 events, median of 11 timed samples after a
warmup round, parity-checked across all strategies. We’re using node’s
default V8. The bench file is in the repo; you can rerun it.)
The thing we want to draw attention to is not row 7 in isolation. It’s the gap between rows 6 and 7. The inline-plain-event optimization landed in a fifty-line change to the JS emitter, on a Saturday evening, and roughly doubled dispatch throughput. That’s the kind of low-hanging fruit you only get when the surface is two days old. The current emitter is a first draft of an emitter — written quickly, optimized opportunistically, nowhere near settled. The same shape of optimization almost certainly exists in several other places we have not yet looked.
Row 7 beats row 5 — the “theoretical ceiling” for object-free static
dispatch in hand-written JavaScript — by a factor of about 1.6×. That isn’t
because Koru is doing something cleverer than the hand-written JavaScript
inside V8. It’s because Koru fuses the event producer and the event
consumer at compile time: the producer’s loop and the consumer’s dispatch
end up in the same function, and there is no events[] array to read from
on each iteration. The producer-consumer fusion is a structural advantage,
not a micro-optimization. Hand-written JavaScript can’t do it because the
binding between producer and consumer in idiomatic JS is dynamic.
What we’re hunting
Here is where we are openly speculating.
The hypothesis that drove the JS-backend work in the first place is that the structural moves Koru makes — resolving dispatch at compile time, representing control flow in the type system, refusing to ship runtime machinery for things the compiler can reason about — should pay off on any backend. That’s the bet. JavaScript is where we’re testing it.
JavaScript is, however, a harsher backend to demonstrate the compile-time-resolution bet on. V8 is the most heavily optimized JIT in the world, but it is optimizing a runtime that resolves dispatch at runtime, allocates objects on every event, and treats function calls as polymorphic by default. The Koru-on-V8 question is: can the compile-time-resolution bet survive contact with a backend that already invested a billion dollars in runtime-resolution?
Tonight’s evidence is one data point on that question, and it is more positive than we expected to see this early. The emitted JS is around four times faster than the idiomatic Node pattern on the dispatch shape we measured. We are speculating openly when we say: if this compounds across the stdlib, the eventual shape is JavaScript programs that ship as performance-grade as the things you would normally drop down to a separate runtime to get. We have one data point. The data point is not the bare minimum we were prepared to accept; it is a multiple of what we expected. We do not have ten data points yet. We will.
The other half of why we are doing this, and we want to be plain about it: JavaScript is a famously resource-unsafe language. Files get opened and never closed. Database transactions get started and abandoned. Event listeners pile up and leak memory for hours. The JavaScript ecosystem has not produced a serious answer to this problem because the language has no type-system primitives for it. Koru does — phantom states, obligations, auto-discharge. Bringing Koru to V8 is, partly, an attempt to bring resource safety to a runtime that has historically had neither safety nor speed, on the kind of code people actually write there. The performance story and the safety story are not separate threads. They are two consequences of the same compile-time-resolution bet.
What JSX got close to and missed
JSX, Svelte, the modern JavaScript frameworks — they compile away too, in
some sense. JSX gets compiled to React.createElement(...). Svelte compiles
its templates to imperative DOM operations. They each run a build step.
But they each end up calling into a runtime library that has its own engine. The compile-time bytes become runtime bytes that get interpreted by a framework that has to exist alongside the program. The framework is where the dispatch lives. The framework is what you ship in your bundle. You moved some of the work to compile time, but the runtime engine is still in the boat.
What we’re chasing — and what tonight’s print.blk variant is one step
toward — is the stricter shape. The template compiles to emitted host
code that calls nothing. No createElement. No framework method. No
runtime helper. The host language’s primitive operations and the user’s
own functions, period. The JavaScript that came out of tonight’s hello
world does not import anything. It is not running on top of a Koru runtime
library because there is no Koru runtime library to run on top of. Koru is
not a framework that emits JavaScript. It is a language whose compiler
happens to be able to target a JavaScript host.
We are nowhere near this across the stdlib yet. One transform got there tonight. Several more should follow over the coming sessions; the recipe is now known.
What this is and what it isn’t
What this is, plainly:
- One
[comptime|transform]stdlib procedure (std.io:print.blk) has a working JavaScript variant. Hello world emits bit-identical output on both targets. - The compile-time dispatch pipeline now respects
--lang=<target>and auto-selects the matching variant. Adding the next variant for the next stdlib item should be a contained, mostly mechanical change. - The runtime dispatch shape we measured is meaningfully faster than idiomatic Node on a synthetic but vaxis-realistic benchmark, and the emitter has visible low-hanging optimizations remaining.
What this isn’t:
- Most of the standard library is still Zig-only. We have a recipe; we have not yet applied it.
- The JS emitter is two days old and has at least two emit bugs we caught and pinned this weekend (a host-line routing leak, a dispatch-binding name-shadow). There are almost certainly more.
- The benchmark is a synthetic dispatch loop, not a real application. We trust the structural reasoning the numbers expose more than we trust the numbers themselves as predictive of any particular workload.
- A single-target program (one
.kjsfile, or one.kzfile) is clean. Going cross-target — same source compiles to both — currently means splitting into a stem and writing per-target host-side declarations twice, once in each facet. For hello world (threeconsts) it’s acceptable; for substantial host-side data it’s meaningfully ugly. The cleaner shape is to declare data in Koru itself and let the compiler lower it to the target host’s syntax. The architecture supports it; we haven’t asked the language to do it yet. - Nothing here is shipped. This is one passing test and one bench file in a research compiler.
We are writing it down because it is the kind of result we did not expect to be writing about this weekend, and because the structural fact — the template doesn’t exist at runtime — generalizes beyond the one transform that demonstrated it.
If the compounding speculation holds up, what we are looking at is the beginning of a JavaScript output target where most of the runtime cost that JavaScript programs pay was eliminated at compile time, in a language that also happens to know how to track files and connections and prevent them from leaking. We are speculating. We are also not exaggerating the speculation.
Tonight’s number is 2.67 ns per event. We will write down the next ones as we get them.