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.
import web and have a server. A separate library would undercut that promise entirely.
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.
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.
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.
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.
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.
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).
Every stage of the pipeline is inspectable. ometer lex,
ometer parse, ometer compile expose tokens, AST, and bytecode. No black boxes.
Each layer was fully tested before the next was started. Lexer verified before parser. Parser verified before compiler. Compiler verified before VM.
Ometer programs have zero npm dependencies. The language, VM, and web server run purely on
Node's built-in http module and TypeScript.
The language has exactly what it needs and nothing extra. No module system complexity, no generics, no overloading. Clean, learnable, writable.
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.
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.
Ometer is open source. The entire language is five TypeScript files.
→ Get Started