Compiler extensions
The compiler is an extensible pipeline
Section titled “The compiler is an extensible pipeline”The compiler is a pipeline of passes (section 22) exposed as a standard library: lexer, parser, type-checker, lowering and codegen, each stage a unit of stable boundary. A compiler extension is a piece of code that adds a new pass to the pipeline, or extends an existing one; it is compiled before everything and executed in the compilation pass. It is the mechanism by which the language grows in capability without the core growing.
An extension can add four things: (i) file types and their syntax (an own grammar for a new file extension), (ii) decorators (with the codegen that realizes them), (iii) codegen targets (emit JS, WASM, whatever), and (iv) pipeline passes (an analysis, a transformation). All additive.
The inviolable rule is that an extension is add-only: no extension touches the core. It does not redefine what fn means, does not change the semantics of an existing construct, does not break code that compiled. The value of the core is the stability of decades; an extension that could rewrite it would destroy exactly that. The boundary is absolute: the core is inviolable, the extension only adds on top.
And this is not a review rule, it is a property of the interface: the API the extension consumes exposes registering a pass, a decorator, a file type, a codegen target, and nothing that mutates the core. There is no function to redefine fn, rewrite the core grammar or alter existing semantics; the redefinition is not forbidden, it is inexpressible, the usual ruler (make the bad impossible to write, not watch over it). The side effects (read a file, subprocess) live in the capability sandbox; the power to touch the core simply does not exist in the API.
Declared capabilities: the extension’s boundary
Section titled “Declared capabilities: the extension’s boundary”An extension runs at compile time, arbitrary code in your build, like a Rust proc-macro or a GCC plugin. The risk is the usual one: a compromised extension, a compromised build. The answer is the language’s own philosophy applied to the compiler: an extension is a system, and its boundary is the set of capabilities it declares. In the mk.project:
[extensions.ui]reads = [".mkoui", ".css", ".d.ts"] // input files (and vendored schemas)writes = ["dist/ui/"] // auxiliary outputs, scopedcodegen = ["js", "wasm"] // what it emitssubprocess = ["wasm-opt"] // non-hermetic hatch: a mature external toolreads and writes scope file input and output; codegen is what the extension produces; subprocess is the only non-hermetic hatch: calling a mature external tool (binaryen’s wasm-opt, a CSS minifier) instead of reimplementing it, declared precisely because the build now depends on a program from outside. There is no network: fetching from the network at build time breaks reproducibility (it is non-deterministic, non-hermetic, and an attack vector), and the case that seems to require it (a remote schema) is better solved by vendoring the schema and reading the local file (reads). The build is hermetic by design.
And there is a route to total hermeticity even with an external tool: vendoring the tool as WASM and running it inside the comptime interpreter, deterministic, with no host binary and no subprocess. The subprocess keeps existing as the explicit and declared hole (you read the mk.project and see that that build calls wasm-opt, so it is not fully hermetic); the WASM in comptime is like closing the hole. The good practice of whoever publishes an extension that depends on a tool is to vendor or ship alongside a trusted binary (ideally WASM), or require in the mk.project that the host have a trusted and compatible version installed: the extension declares which version it depends on, and the build fails early if it does not match, instead of silently spitting out a different artifact.
This is explicitness by marking (section 1) in the build: you read the mk.project and know what each extension can touch. The declaration is the design commitment (and already gives an audit even without a sandbox); the enforcement (the sandbox that blocks the undeclared) hardens with the maturity of the compiler.
Note: this list is a sketch. The capabilities above (
reads/writes/codegen/subprocess) and their values are provisional, not the final specification. The complete set of capabilities, the exact names and all the possible values only close in the implementation, when the real pipeline exposes its extensions and it is discovered what an extension actually needs to declare. What is fixed is the principle (add-only, capability-declared, hermetic exceptsubprocess); the table is a starting point.
How you choose, and where they come from
Section titled “How you choose, and where they come from”Extensions are explicit, never hidden: turned on by a flag (mk build --ext=ui) or fixed in the mk.project. No extension enters on its own through a transitive dependency (the problem of proc-macros), because the set of a build is always written, in one place. They can be official (maintained with the language) or third-party (anyone writes them), distributed like any package. Since the compiler is the pipeline exposed as a library, writing an extension is writing code against that library: registering a pass, a decorator, a target.
The first official extension: UI
Section titled “The first official extension: UI”The UI is the first official extension, and it is the example of why the mechanism exists. It adds a file type (.mkoui, with its markup grammar), a set of decorators (@state/@derived/@effect/@component), and codegen targets (JS on the client, WASM, SSR), and provides the codegen that realizes them. .mko files are the core (clean grammar, no markup); .mkoui files turn on the extension’s extended grammar. The file extension is the opt-in (section 1): markup never pollutes the core (a .mko never sees a <div>), and whoever does not do UI never touches it.
The UI is an extension, not core, precisely because the volatile part of it is volatile. The reactivity model (fine-grained, SolidJS style, direct diff over the DOM, no Virtual DOM) is a 2026 paradigm, and reactive paradigms turn over every few years. Isolating it in an extension is what lets it age and be replaced without touching the core: if the web changes its model, you swap the extension, and no .mko notices. What is stable (the server/client boundary, which is the network channel of section 4, the HTML/CSS target, the @jsimport) sustains the bet; what is volatile (the reactivity) lives in a box that can turn over. The bet continues, now with the right name (extension) and the right place (outside the core).
The @jsimport is part of this extension, not of the core: importing types from .d.ts and interoperating with JS is the UI extension’s territory. The @cimport and the inline assembly (sections 17 and 18) stay in the core, because they are stable FFI, of a low boundary, that does not change; the ruler is exactly “the stable stays, the volatile becomes an extension”.
Reactivity: fine granularity, no Virtual DOM
Section titled “Reactivity: fine granularity, no Virtual DOM”The reactivity model is fine-grained, SolidJS style: each piece of state knows exactly which parts of the UI depend on it, and only those update when it changes. There is no re-execution of the whole component (the React model), and there is no Virtual DOM: when a state changes, the update goes directly to the affected piece of DOM. On the client, this is a direct diff over the browser’s real DOM (you compare against the DOM that exists, not against a virtual tree in memory).
Reactivity is expressed by decorators, not keywords (in place of the “canonical keywords” registered earlier):
@state count := 0 // reactive state: changed → re-renders whoever depends@derived doubled := count * 2 // computed: recomputes when 'count' changes@effect { log("count = {{count}}") } // effect: runs when its dependencies changeEach decorator becomes the core construct plus the reactive plumbing the compiler injects: @state is a mutable variable plus dependent-tracking; @derived is a variable plus automatic recomputation; @effect is a channel plus firing when the dependencies change; @context is a struct plus propagation down the component tree. Without the decorators, you would have the raw constructs (variable, channel) without the machinery, and the decorator is what turns on the reactivity. (The := in a @state/@derived is the reactive binding, mutable by construction, since reactivity requires mutation; you do not write var because the decorator already implies it.)
A @context provide (@context theme := make_theme()) is keyed by the provided value’s nominal type; a consumer receives it through a parameter marked @context (fn Leaf(theme: Theme @context)), injected from the ambient tree rather than passed as a prop. A T @context consumer with no provider of T anywhere in the program is a compile error — an unsatisfied dependency, exactly like a missing required prop — not a silent empty render. A provider in any component satisfies the dependency (the tree is dynamic, so the check is presence-of-a-provider, not a static ancestry proof).
Components and layouts
Section titled “Components and layouts”A component is a function that returns markup, and the props are the parameters: no new concept, it is the usual function with markup in the return {} (the language has no implicit return; markup is the content of a return block):
@componentfn Greeting(name: string, count: int) { return { <div class="card"> <h1>Hello, {{name}}</h1> <p>You have {{count}} messages</p> </div> }}@component marks the function; the return {} carries the markup; {{expr}} interpolates text (the string interpolation, section 16). Using it is instantiating, passing props: <Greeting name="Ana" count={5} />.
The binding in the markup has two forms, by position. {{ }} interpolates content (text via Display, or a slot/markup) between tags; { } binds an expression (a value, or a closure) after an =, be it a prop, a dynamic attribute or a handler. Rule of thumb: content between tags is {{ }}; a value after = is { }. No overlap: count={5} passes the int, <p>{{count}}</p> displays the text, onclick={fn} binds the closure.
A layout is UI that wraps several pages (persistent header, nav, footer), just a component of a specific kind, @component(layout), whose parameters are the slots (where the pages’ content enters):
@component(layout)fn MainLayout(content: slot) { return { <header><nav>...</nav></header> <main>{{content}}</main> // the slot: content, so {{ }} <footer>...</footer> }}The slot is the “hole” where the child content enters (React’s children). Several slots, several params. Routing (binding layouts to URLs) is not part of the extension; it is a lib and a convention on top (to allow file-based Next style or explicit TanStack style), because it is a framework decision, not a language one.
Where the code runs: @server and @client
Section titled “Where the code runs: @server and @client”UI code runs in two places (server and client). Instead of marking all code with where it runs, you mark only what is bound to one side, and the rest crosses by default:
@server fn load_user(id: int) -> User { return db.query(...) } // server only: never goes to the client@client fn on_scroll() { ... } // client only: runs in the browser (WASM)
fn format_date(d: Date) -> string { ... } // no marker: runs wherever it is called@server: the restriction “can only run on the server” (depends on a database, secrets, filesystem). Never sent to the client.@client: the restriction “can only run on the client” (manipulates the DOM, responds to events). Goes to the browser as WASM.- No marker: a normal function, with no side restriction. The compiler compiles it for wherever it is called (from the server it becomes server, from the client it becomes client, from both it becomes both). Pure logic (formatting, validation) is this: write once, cross without ceremony.
@server and @client are restrictions, not mandatory markers: you annotate only what is genuinely bound to one side, and the common one crosses (like pub/priv, where the restricted case is the annotated one). The restriction propagates upward: a function without a marker that calls something @server becomes server-only (depends on a database, so it cannot run on the client), the compiler infers this and bars you if you try to call it from the @client. And if it calls both @server and @client, it would be bound to both sides at once, with no valid side to run on, which is a compile error (the propagation takes it to an impossible set). The division matters for security (@server code with secrets never leaks) and for size (only the necessary becomes WASM).
Islands: only the reactive goes to the client
Section titled “Islands: only the reactive goes to the client”The execution model is islands, not full-page hydration. Most of a page is static HTML rendered on the server (text, layout, content that does not react). Only the interactive pieces, the “islands”, become WASM on the client, carrying only the reactivity of that island. The rest stays dead (pure HTML, zero WASM).
This avoids React’s cost (re-executing and hydrating the whole page on the client): you send to the browser only the reactivity of the parts that have reactivity. A page with a static header, a static article and an interactive “like” button sends dead HTML for the header and the article, and a tiny WASM island only for the button. (That is why full hydration and Qwik’s resumability stay out: hydrating a small island is cheap, so the heavy machinery of serializing the whole graph does not pay off here.)
And the hybrid state falls out of the @server/@client: a @state in a @client island lives on the client (local, instant, with no round-trip, like a counter that counts in the browser); a @state in @server lives on the server (it changes there, and the server sends the updated HTML fragment). The state lives where it is declared: the ephemeral UI one (is the dropdown open?) stays local in the island; the domain state (data, session) stays on the server. There is no global model of “where the state lives”; each @state lives in its context.
The server/client boundary is a network channel
Section titled “The server/client boundary is a network channel”When an island on the client needs to talk to the server (fetch data, send an action), the communication is a network channel (section 4), not a new UI mechanism. Server and client are two isolated worlds, and they talk through the usual Channel[T], obtained by a network operation that requires T + Serializable and can fail:
@clientfn LikeButton(post_id: int) { @state liked := false @derived icon := liked ? "♥" : "♡" // ternary: binary choice
fn handle_click() { chan := net.connect[LikeAction]("/api/like") catch |e| { return } chan <- LikeAction{ post: post_id } reply := -> chan timeout(3s) catch |e| { return } // receive with timeout/catch (section 4) liked = reply.ok }
return { <button onclick={handle_click}>{{icon}}</button> // handler: { } ; text: {{ }} }}The client’s island treats the server as a remote process: it sends a message, waits for a reply, with catch and timeout because the network can fail. It is your whole concurrency model (isolated worlds plus channels) extended to the server/client boundary: the same catch, the same timeout, the same form. No new UI concept for communication, just the network channel that already exists.
Scoped CSS
Section titled “Scoped CSS”CSS lives next to the component: you write <style> in the markup, or use direct classes (class="card"). By default, the style is scoped to the component, because the compiler hashes the class, rewriting .card to a unique name (.card_a3f8) and adjusting the markup along with it, so that one component’s .card never collides with another’s .card:
@componentfn Card(title: string) { return { <div class="card"> <h2 class="title">{{title}}</h2> </div> <style> .card { padding: 1rem; border-radius: 8px; } .title { font-weight: 600; } </style> }}// '.card' and '.title' become '.card_a3f8' / '.title_a3f8', isolated to this componentThe classes are verified. Since the compiler sees the <style> and the markup in the same component, it checks that every class="X" corresponds to a .X defined: class="crad" (typo) is a compile error, not a style that silently does not apply. It is what typed CSS Modules tries to do with a hack in TS; here it is native, because the compiler already has both halves.
For a style that should leak (a reset, a theme, styling a lib), the escape valve is @scope(global), which turns off the hashing in that block, and the selectors are valid globally:
@scope(global)<style> :root { --brand: #0a7; } // global variables, not scoped body { margin: 0; }</style>External CSS (Tailwind, a design system, a third-party reset) enters via @css("file.css"), and is always global, without hashing: Tailwind defines .flex, .pt-4 with fixed names that your markup uses (class="flex pt-4"), so hashing those classes would break them (they would not match the class="flex" you write). That is why imported CSS preserves the names:
@css("tailwind.css") // global classes, names preserved<div class="flex pt-4">...</div> // uses Tailwind's classesAnd those external classes enter the verification: the compiler reads the .css (which it is going to process anyway), so class="flex" is valid and class="flexx" (typo) is still an error. You gain typo-checking of Tailwind’s classes, which not even Tailwind has. The only limit is that you cannot scope what came from outside (the names are fixed by definition).
Dynamic style uses the binding { }: <div class={cls}> takes the class from an expression. And style={...} is a struct of structs, CSS-in-JS but typed: you assemble the style as language data (checked fields, reactive values), instead of a loose string:
@state active := false<div style={ {background: active ? "#0a7" : "#ccc", padding: "1rem"} }>...</div>The CSS processing is configurable (optimize/none): optimize minifies and removes the unused; none passes the CSS as is (besides the scope hashing, which is structural).
FFI of JS/TS: @jsimport
Section titled “FFI of JS/TS: @jsimport”CSR runs as WASM in the browser, and WASM does not talk to the DOM directly, it goes through JS. So the language needs to call JS, and the mechanism is @jsimport, analogous to @cimport (section 17): it brings JS inside as a module, all qualified under js.. The WASMJS bridge is the @jsimport itself: the compiler generates the glue (the wasm-bindgen glue) automatically, you write no bridge. The DOM, fetch and scrollY are just browser APIs, imported like any JS:
@jsimport("dom", browser) // the DOM API, marked browser-only@jsimport("react") // third-party lib (default: runs on both)
el := js.dom.getElementById("app") // all under 'js.', WASM↔JS glue generated by the compilery := js.window.scrollYThe types come in a cascade (JS is dynamic; C had a header, JS does not have a native type):
- Automatic
.d.ts(default): most of the ecosystem has types in.d.ts, and the compiler reads them and generates the typed interface, as@cimportreads a.h. - Manual declaration: when there is no
.d.ts, you declare the signatures of what you import. - Dynamic: a call without types (
js.call(...)), marked when you want, or as an automatic fallback per symbol. If a.d.tssymbol uses a TS type the language cannot express (conditional types, exotic unions), that symbol falls to dynamic with a warning, and the lib stays typed where it can, dynamic where it cannot. It prioritizes compatibility (nothing blocks because of an exotic type) with visibility (the warning marks where you are without checking, resolvable by using dynamic on purpose).
The environment is declared and propagates the restriction. @jsimport("dom", browser) marks the import as browser-only; @jsimport("fs", node) as server-only; unmarked third parties run on both. The restriction propagates like @server/@client (above): calling js.dom from code that runs on the server is a compile error, not the window is not defined that JS discovers at runtime. You trade the typeof window !== "undefined" (runtime, breaks if you get it wrong) for a compile guarantee.
Marshalling reuses Serializable (section 4). Passing a value WASMJS is the same problem as crossing the network: different heaps, so what crosses needs to become bytes. Numbers pass directly (same representation); strings and structs marshal (copy or conversion), and the compiler requires Serializable on what crosses, the same concept as the network channel, not a separate marshalling.
And @jsimport runs on both sides: the browser (CSR) and server JS runtimes (Node/Deno/Bun). This is deliberate for adoption: the language fits into the existing JS ecosystem (you import JS libs you already use), reducing migration resistance.
Transpile to JS
Section titled “Transpile to JS”CSR runs in WASM, which works in practically every modern browser. For the rare environments where WASM does not run, the extension transpiles to JavaScript, but only the .mkoui, not the whole language. Transpiling the whole language to JS would be enormous scope-creep (a second complete backend, with features that have no JS equivalent like @mm, pointers and the process model); what is transpiled is enough for the UI to work without WASM.
It is an explicit build (--target=js), opt-in: by default you compile to WASM; only whoever needs the JS target asks for it, and whoever does not need it receives neither the cost nor the path. And the parity is total: fine reactivity, islands and components work identically in JS and WASM, and the JS target is not a degraded version, it is the same behavior by another backend.
Repeated markup
Section titled “Repeated markup”Markup repeated once per element of a collection is written with the same loop the core language uses, as a markup child:
@componentfn List(xs: []int) { return { <ul>loop x in xs { <li>{{x}}</li> }</ul> }}loop <var> in <iterable> { <body> } renders <body> once for each element, with <var> bound to it (a list, slice or array); the pieces concatenate in order. The { after the iterable opens the loop body, never a struct literal — the iterable is a control-clause expression, as in the core loop. It is not a new construct: it is the language’s loop reused in content position, so nothing UI-specific is learned. The dual, functional form is an ordinary expression in a {{ }} hole ({{ xs.map(fn(x: int) => ...) }}); both express the same iteration, chosen by taste. Under the JS target the loop lowers to (xs).map((x) => …).join(""), byte-identical to the WASM/SSR render.
Routing and virtualization: library, not core
Section titled “Routing and virtualization: library, not core”Two things look like UI but are a lib and a convention over the extension, not part of the language.
Routing (binding URLs to components and layouts) is a library, and on purpose, to allow both file-based (routes inferred from the file structure, Next style) and explicit (routes declared in code, TanStack style). The core exposes no hook for this; everything a router needs (render a component, read the URL via @jsimport) already exists.
Virtualization (rendering only the visible items of a huge list, recycling nodes) is also a library, buildable with @state (the scroll position), the browser’s @jsimport (the real scrollY and the element measurements come from the browser API, imported like any JS) and conditional markup (renders the visible window). The core needs no primitive; it is a pattern over what already exists.