← back

A language, top to bottom

My own programming language, from parser to machine code.

built
April - May 2026
stack
Go, C, Rust, Cranelift, LLVM

Why

After twelve years of writing programs, I wanted to understand the thing that runs them, so I went down the stack on purpose: write a language, make it fast, then make it native. It started from Thorsten Ball's Writing an Interpreter in Go and grew well past it.

The pipeline

The language pipeline: source goes through a lexer and a Pratt parser into an AST; the AST can be run by a tree-walking interpreter, checked by a type checker, compiled to bytecode for a stack VM, or lowered to an IR and compiled to native code with Cranelift or LLVM. source lexer Pratt parser AST interpreter tree-walking type checker static types bytecode compiler lower to IR Go frontend stack VM Cranelift LLVM native binary + C runtime

1. An interpreter that grew up

The book ends with a small language: integers, strings, functions, arrays, hashes. I kept going until it was something I could write real programs in:

What it looks like

Some of the things I added along the way:

Optional static types

let add = fn(a: int, b: int) -> int { a + b };

add(1, "two");
// type error: cannot pass string as argument 2 of type int

let x: int = null;
// type error: cannot assign null to int

async / await, try / catch

let results = await_all([async { 1 }, async { 2 }, async { 3 }]);

try {
    await async { throw "oops" };
} catch(e) {
    print("caught: ${e}");
}

Destructuring, interpolation, for-in

let [first, second] = ["hello", "world"];

let n = 5;
print("n squared is ${n * n}");   // n squared is 25

for (name in ["ana", "ben"]) {
    print("hi ${name}");
}

A web server, written in the language

import "packages/http.mo";
import "packages/router.mo";

let r = router_new();

router_get(r, "/users/:id", fn(req) {
    http_ok("user id is " + req["params"]["id"])
});

http_serve(8080, fn(req) { router_dispatch(r, req) });

Tests, also in the language

import "packages/assert.mo";

suite_begin("async / await");

test_case("await resolves a string", fn() {
    let f = async { "hello" };
    assert_eq(await f, "hello", "should resolve to hello");
});

The standard library

Every package below is written in the language itself, on top of a handful of builtins from Go.

2. Bytecode and a virtual machine

Tree-walking is simple and slow. The second step (following Writing a Compiler in Go) compiles the AST to bytecode and runs it on a stack-based virtual machine: opcodes, a constant pool and stack management, with tests for both the compiler and the VM. This part is still in progress.

3. Down to machine code

Then the last step: no VM at all. A frontend in Go emits a small intermediate representation, and a backend in Rust hands it to Cranelift (the code generator behind Wasmtime) to produce a native object file, linked with a small C runtime into a real executable. In parallel I wrote LLVM IR by hand and compiled it with clang, to see how the two code generators compare.

Around it

Reading list

What I worked through alongside it: Crafting Interpreters (Nystrom), Writing an Interpreter in Go and Writing a Compiler in Go (Ball), Engineering a Compiler (Cooper, Torczon), Computer Systems: A Programmer's Perspective (Bryant, O'Hallaron), Operating Systems: Three Easy Pieces, The C Programming Language (K&R), and Beej's networking guide.