// How It Was Built

A language
from scratch.

Ometer is not a framework or a transpiler. It's a ground-up programming language — custom syntax, custom compiler, custom VM. Every line of it built in TypeScript.

Source files
5
Opcodes
36
AST nodes
24
Language
TS
01
Lexer
lexer.ts
✓ Done
02
Parser + AST
parser.ts / ast.ts
✓ Done
03
Compiler
compiler.ts
✓ Done
04
Virtual Machine
vm.ts
✓ Done
05
Web Framework
vm.ts (stdlib)
✓ Done
// Design Decisions
Why each choice
was made.
Why TypeScript?
Safety at the meta level. Building a dynamic language is full of edge cases. TypeScript's type system catches bugs in the interpreter itself — not just in Ometer programs.
Why a custom VM instead of transpiling to JS?
Full control. Transpiling to JS means inheriting JavaScript's semantics, scoping rules, and edge cases. A custom VM means Ometer's behaviour is 100% defined by Ometer.
Why dynamic typing?
Speed of iteration. For a web framework language, the friction of type declarations hurts more than it helps. Dynamic typing keeps .om files clean and expressive.
Why a stack-based VM over a register-based one?
Simplicity. Stack VMs are easier to implement correctly and debug. The bytecode is compact and the execution model is straightforward — no register allocation needed.
Why a recursive descent parser?
Readability and control. A hand-written recursive descent parser maps 1:1 to the grammar. It's easy to extend, gives great error messages, and requires no parser-generator tooling.
Why built-in web framework vs a library?
Zero-friction onboarding. The whole point of Ometer is that you write import web and have a server. A separate library would undercut that promise entirely.
// The Build Story
Layer by layer.

Ometer was built in strict pipeline order. Each layer depends only on the output of the previous one. No layer was started until the previous one was fully tested.

01

The Lexer

The first step was turning raw .om source text into a flat list of typed tokens. The Lexer in lexer.ts is a single-pass character scanner that recognises keywords, identifiers, string literals (with escape sequences), numbers (integers and floats), all operators including compound ones (+=, **, ->), and single/block comments.

Every token carries its type, raw value, and a line:column position — critical for error messages in every downstream layer. The Lexer throws a LexerError on unknown characters or unterminated strings.

// Input: let name = "Ometer"
// Output token stream:
LET        "let"     L1:1
IDENT      "name"    L1:5
ASSIGN     "="       L1:10
STRING     "Ometer"  L1:13

The full token set covers 50+ types — literals, 12 keywords, 20+ operators, and delimiters. The design principle: the lexer does zero interpretation. It classifies bytes. Nothing more.

02

The Parser & AST

With tokens in hand, the parser in parser.ts builds an Abstract Syntax Tree. The grammar is implemented as a hand-written recursive descent parser — each grammar rule maps to exactly one method.

The AST is defined in ast.ts as a TypeScript discriminated union of 24 typed node interfaces. Every node carries a kind discriminator and a line number. The compiler can safely switch on node.kind with full type inference.

// fn add(a, b) { return a + b } becomes:
{
  kind: "FnDecl", name: "add",
  params: ["a", "b"],
  body: {
    kind: "BlockStmt", body: [{
      kind: "ReturnStmt",
      value: {
        kind: "BinaryExpr",
        left:  { kind: "Identifier", name: "a" },
        operator: "+",
        right: { kind: "Identifier", name: "b" }
      }
    }]
  }
}

Expression precedence is handled through the recursive descent hierarchy: assignment → logical-or → logical-and → equality → comparison → addition → multiplication → power → unary → postfix → primary. Eleven levels, cleanly separated, each a distinct method.

03

The Compiler

The compiler in compiler.ts walks the AST depth-first and emits bytecode instructions. Each function body (including the top-level program) becomes a Chunk — a named unit containing an instruction list and a list of child Chunks for nested functions.

The most interesting part is jump back-patching. When compiling a while loop, the compiler emits a JUMP_IF_FALSE with a placeholder target, compiles the loop body, emits a backward JUMP, then patches the exit jump to point to the instruction after the loop. The same technique handles break and continue.

// while (i < 3) { print(i) } compiles to:
0000  LOAD        "i"
0001  PUSH        3
0002  LT
0003  JUMP_IF_FALSE  0007   ← patched
0004  LOAD        "i"
0005  PRINT
0006  JUMP        0000   ← back edge
0007  ...

Short-circuit && and || use a DUP + conditional jump pattern: duplicate the left value, test it, jump if short-circuiting (leaving the original on the stack), otherwise pop and evaluate the right side.

04

The Virtual Machine

The VM in vm.ts is a stack-based interpreter. It maintains a frame stack — each function call creates a new Frame with its own instruction pointer, value stack, and Scope. Frames are pushed on CALL and popped on RETURN.

Scopes are implemented as linked-list environments. Variable lookup walks the chain from the current scope up to the global scope. This gives Ometer proper lexical scoping and closures — functions capture the scope they were defined in via fn.closure.

// Closure example — works correctly:
fn counter() {
  let n = 0
  return fn() { n += 1; return n }
}
let c = counter()
print(c())  // 1
print(c())  // 2 — n persists in closure

The web framework lives entirely inside the VM as native functions. web.create() returns an OmObject with native route registration methods. app.listen() spins up a real Node.js http.createServer and dispatches incoming requests through Ometer functions — calling back into the VM for each route handler.

05

The Web Framework

The web stdlib is implemented as a set of OmNativeFunction values — TypeScript functions wrapped in Ometer's runtime type system. When Ometer calls app.get(), the VM calls the native TypeScript function, which registers the route. When a request comes in from Node's http module, the VM is called back to execute the Ometer handler.

Route matching supports URL parameters with :param syntax. The matcher splits both the registered path and the request path on /, aligns them, and extracts named segments into req.params. Query strings are parsed into req.query. Request bodies are parsed as JSON and converted into Ometer objects via the jsToOm bridge.

app.get("/users/:id", fn(req, res) {
  let id = req.params.id   // "42" from /users/42
  res.json({ id: id })
})

The middleware chain is an ordered array of Ometer functions. Each receives (req, res, next) where next is a native closure that advances the handler index. Route handlers sit at the end of the chain and receive only (req, res).

// Source Map
Every file and
what it does.
src/lexer.ts
~280 lines
Character scanner → typed token stream. Handles all Ometer syntax, escape sequences, comments, line tracking.
src/ast.ts
~160 lines
TypeScript discriminated union of 24 AST node interfaces. Every language construct has a typed node.
src/parser.ts
~340 lines
Recursive descent parser. 11-level expression precedence hierarchy, full error messages with line/col.
src/compiler.ts
~490 lines
AST → bytecode. 36 opcodes, jump back-patching, function chunk tree, short-circuit boolean compilation.
src/vm.ts
~520 lines
Stack-based VM with call frames, lexical scope chains, closures, native stdlib (math, str, arr), and full web framework with routing, middleware, and HTTP server.
src/cli.ts
~60 lines
CLI entrypoint. ometer run / lex / parse / compile commands, banner, file reading.
// Design Principles
Built with intent.
01
Transparency

Every stage of the pipeline is inspectable. ometer lex, ometer parse, ometer compile expose tokens, AST, and bytecode. No black boxes.

02
Correctness First

Each layer was fully tested before the next was started. Lexer verified before parser. Parser verified before compiler. Compiler verified before VM.

03
Zero Dependencies

Ometer programs have zero npm dependencies. The language, VM, and web server run purely on Node's built-in http module and TypeScript.

04
Minimal Surface

The language has exactly what it needs and nothing extra. No module system complexity, no generics, no overloading. Clean, learnable, writable.

05
Web First

The web framework isn't a bolt-on. HTTP, routing, and JSON responses are built into the VM itself — they're as native as arithmetic.

06
Hackable

Every layer is a readable TypeScript file under 520 lines. Adding a new opcode, a new AST node, or a new stdlib module takes minutes.

// What's Next
The road ahead.
v0.2.0 — Stdlib expansion
File I/O, JSON module, environment variables, more array and string methods, a proper error type with stack traces.
v0.3.0 — Classes / structs
A lightweight object model with methods defined inline. No inheritance, no complexity — just structured data with behaviour attached.
v0.4.0 — Package system
Multi-file .om programs with an import resolution system. Build Ometer apps spread across multiple files.
v0.5.0 — Optimizer
A bytecode optimization pass: constant folding, dead code elimination, inlining small functions.
v1.0.0 — Stable release
Stable language spec, comprehensive stdlib, npm-installable CLI, full documentation site, VS Code syntax extension.

Read the code.
Fork it. Break it.

Ometer is open source. The entire language is five TypeScript files.

→ Get Started