June 6, 2026 · Umar · 6 min read
I Made A Programming Language In Dart
I built a compiler. Firn is a small statically-typed language that lowers all the way to a native executable through LLVM. Give it this:
function add(a: int, b: int): int {
return a + b;
}
let x: int = add(5, 10);
if (x > 10) {
print("big");
} else {
print("small");
}
and it produces a real binary that prints big.
The binary is the trophy. It isn't the point. The point is that a handful of things I'd understood only loosely for years finally locked into place — and I want them written down while they're still sharp.
Code's here: firn
A compiler is a translator
That's the whole premise. Take text a human wrote, turn it into something a machine runs. The stages aren't ceremony — each one takes something messy and hands the next one something cleaner.
The whole pipeline rides a single gradient:
text → tokens → tree → typed tree → IR → machine code
raw words grammar meaning portable concrete
Messy on the left, concrete on the right. Every stage advances you exactly one step, and every stage exists for one reason: the next one refuses to work with dirtier input. Once I read it that way, nothing about the design felt arbitrary.
Stage 1: the lexer turns characters into words
The lexer reads raw source and chops it into tokens — keywords, numbers, operators — discarding whitespace and comments. let x = 5; becomes [let] [identifier "x"] [=] [intLiteral "5"] [;].
Its output is a flat array. One token after the next, nothing nested. That flatness looks unremarkable, and it's the entire reason the next stage has work to do.
A trap lives here. The lexer tags each token with its lexical category — number, keyword, operator — but it knows nothing about value types like int or bool. Those arrive later, from a different stage. Two concepts share the word "type," and conflating them costs you an hour.
Stage 2: the parser builds a tree
This is the stage that reorganized how I think about parsing.
The tokens are flat. A program isn't. x + 1 < 10 means "a comparison whose left side is an addition." A list can't express that. A tree can.
A linked list is linear — every node has exactly one successor. A tree branches: the + node points at both a and b simultaneously, and a list node has no room for two. That branching is why parsing is a real stage and not a relabeling. Its job is to turn the linear thing into the branching thing.
The parser is pure recursion — one function per grammar rule, the functions calling each other. Peek at the next token to pick the rule, let each rule consume exactly its own tokens and not one more, run a top-level loop until end-of-file. Three small rules, and the nested structure assembles itself.
The detail that got me: nothing ever searches for the next statement. Each rule consumes precisely its tokens and, as a side effect, leaves the cursor resting on the first token of whatever follows. You never reposition the cursor deliberately — it lands correctly because every rule is honest about its appetite. Get each rule's consumption exactly right and the hand-off between statements is automatic, all the way down.
None of the machinery is new, which is the quietly satisfying part. A cursor, recursion, a sentinel loop — standard data-structures tools, aimed at one job. The single novel move is that the tree doesn't exist yet. You build it as you traverse the input, and the recursion's call stack temporarily stands in for the shape of the tree you're producing.
Stage 3: the type checker stamps meaning onto the tree
"hi" + 5 parses perfectly. It's still nonsense. Catching that is this stage's job.
It does two things at once. It verifies — rejecting the nonsense — and it annotates — recording each expression's type directly on its node. The result is a typed AST: the same tree, but now every node knows what it is.
The implementation is almost anticlimactic. Every expression node carries a resolvedType slot the parser leaves empty. The type checker walks the tree and fills it in. Empty slots becoming filled slots is the entire difference between an AST and a typed AST.
Types propagate upward from the leaves. a is int, b is int, so a + b resolves to int. x > 10 has two integer operands but resolves to bool, because comparison produces a boolean — which is exactly what lets the surrounding if accept it as a condition. Types flowing up the tree like that made the whole pass feel mechanical, in the best way.
Stage 4: the typed tree becomes IR
This is where static typing earns its keep.
Because every node already knows its type, emitting LLVM IR is nearly mechanical. int maps to i32. Integer + becomes add. < becomes icmp. No runtime type-guessing, no tagged values, no per-operation "int or string?" check — the type checker settled all of it at compile time, so the IR stays lean.
That's the case for static typing, made concrete. Do the reasoning once, up front, and the backend's output is direct rather than defensive.
Stage 5: hand it to LLVM
This is the stage Firn deliberately does not implement, and the restraint is the smart part. It writes the IR to a .ll file and invokes clang. clang recognizes the IR, skips its own C front end entirely, and drives LLVM to optimize, select machine instructions for the actual target CPU, and link against libc — which is where print resolves to printf.
Firn orchestrates LLVM rather than writing machine code by hand — the same arrangement Rust and Swift use. Building your own machine-code emitter and linker is a separate, enormous undertaking. Reusing LLVM is exactly the right altitude for a project about understanding the front end.
What actually stuck
The compiler runs. That's satisfying. It isn't the lesson. The lesson is smaller and more durable: none of this is magic. A compiler is a translator that moves text along a gradient from messy to concrete, one well-defined stage at a time. Every stage turned out to be a tool I already owned — a loop, a tree, recursion, a lookup table — pointed at one precise job.
I already want to push Firn toward a real language, and I know that's a far steeper climb than a day's work. Closures drag the heap back in. Generics demand the whole-program inference this project was careful to avoid. Good error messages are a product unto themselves.
But that's tomorrow's mountain. Today I built a compiler — and for the first time, I understand exactly what that sentence means.