Expression Parser
July 13, 2026 ยท View on GitHub
A tokenizer, recursive descent parser, and evaluator for arithmetic expressions - the smallest honest compiler frontend. This is the scenario that defines typed languages with algebraic data types: a token stream, a tree of node kinds, and functions that must handle every kind. Rust would write the AST as an enum, TypeScript as a discriminated union, Java as a sealed interface. This example writes it as a sealed class hierarchy with an exhaustive switch over the subclasses, which gives the same compile-time totality: adding a node kind is a compile error until every function handles it.
Features exercised:
- A
uint8-typed enum for token kinds, driving an exhaustiveswitchin the evaluator so adding an operator surfaces every site that must handle it. - A typed generator as the tokenizer:
function* tokenize(source: string): Tokenyields value type tokens the parser pulls on demand, no token array allocated. Tokenis a value type class - four fixed-width fields - so the generator's yields are copies, not heap objects.- A
sealedclass hierarchy for the AST, discriminated by aswitchwhose case labels are the subclass type objects; with nodefaultthe compiler checks it covers every node kind, and each case narrows to its subclass. - A typed error subclass caught by type:
catch (e: ParseError)picks parse failures out from everything else and carries a typed position.
Tokens
enum TokenType: uint8 { Number, Plus, Minus, Star, Slash, LeftParen, RightParen, End };
class Token { // Value type: enum + float64 + one uint32
type: TokenType;
value: float64; // Meaningful when type is Number
position: uint32;
}
class ParseError extends SyntaxError {
position: uint32;
constructor(message: string, position: uint32) {
super(`${message} at ${position}`);
this.position = position;
}
}
Tokenizer
function* tokenize(source: string): Token {
let index: uint32 = 0;
while (index < source.length) {
const start = index;
const code = source.charCodeAt(index);
switch (code) {
case 0x20: // space
++index;
continue;
case 0x2B: // +
++index;
yield { type: TokenType.Plus, value: 0, position: start };
continue;
case 0x2D: // -
++index;
yield { type: TokenType.Minus, value: 0, position: start };
continue;
case 0x2A: // *
++index;
yield { type: TokenType.Star, value: 0, position: start };
continue;
case 0x2F: // /
++index;
yield { type: TokenType.Slash, value: 0, position: start };
continue;
case 0x28: // (
++index;
yield { type: TokenType.LeftParen, value: 0, position: start };
continue;
case 0x29: // )
++index;
yield { type: TokenType.RightParen, value: 0, position: start };
continue;
}
if (code >= 0x30 && code <= 0x39) { // 0-9
while (index < source.length && ((source.charCodeAt(index) >= 0x30 && source.charCodeAt(index) <= 0x39) || source.charCodeAt(index) == 0x2E)) {
++index;
}
yield { type: TokenType.Number, value: float64.parse(source.slice(start, index)), position: start };
continue;
}
throw new ParseError(`Unexpected character '${source[index]}'`, index);
}
yield { type: TokenType.End, value: 0, position: uint32(source.length) };
}
An object literal where a Token is expected constructs one through the matching-shape cast, the same rule the routing examples in decorators rely on.
AST
sealed class Node {
position: uint32;
}
class NumberNode extends Node {
value: float64;
}
class UnaryNode extends Node {
op: TokenType;
operand: Node;
}
class BinaryNode extends Node {
op: TokenType;
left: Node;
right: Node;
}
NumberNode is a value type; Node is sealed, which the main proposal's sealed-class rule makes a reference type, so UnaryNode and BinaryNode hold their children as plain Node references - a non-sealed value type class would need T | null for the same - and the nodes of a tree are ordinary heap objects, which is what a tree wants.
Parser
Recursive descent with precedence climbing. The parser owns one token of lookahead pulled from the generator:
export function parse(source: string): Node {
const tokens = tokenize(source);
let current: Token = tokens.next().value;
function advance(): Token {
const consumed = current;
current = tokens.next().value;
return consumed;
}
function expect(type: TokenType) {
if (current.type != type) {
throw new ParseError(`Expected ${TokenType.toString(type)}, found ${TokenType.toString(current.type)}`, current.position);
}
advance();
}
function precedence(type: TokenType): uint8 {
switch (type) {
case TokenType.Plus:
case TokenType.Minus:
return 1;
case TokenType.Star:
case TokenType.Slash:
return 2;
default:
return 0;
}
}
function primary(): Node {
if (current.type == TokenType.Number) {
const token = advance();
return { position: token.position, value: token.value } := NumberNode;
}
if (current.type == TokenType.Minus) {
const token = advance();
return { position: token.position, op: TokenType.Minus, operand: primary() } := UnaryNode;
}
if (current.type == TokenType.LeftParen) {
advance();
const node = expression(1);
expect(TokenType.RightParen);
return node;
}
throw new ParseError('Expected a number, unary minus, or parenthesis', current.position);
}
function expression(minimum: uint8): Node {
let left = primary();
while (precedence(current.type) >= minimum) {
const op = advance();
const right = expression(precedence(op.type) + 1);
left = { position: op.position, op: op.type, left, right } := BinaryNode;
}
return left;
}
const root = expression(1);
expect(TokenType.End);
return root;
}
Evaluator
An exhaustive switch over the sealed hierarchy - case labels are the subclass type objects - then a switch on the operator enum inside the binary case:
export function evaluate(node: Node): float64 {
switch (node) {
case NumberNode:
return node.value; // node: NumberNode in this case
case UnaryNode:
return -evaluate(node.operand);
case BinaryNode: {
const left = evaluate(node.left);
const right = evaluate(node.right);
switch (node.op) {
case TokenType.Plus: return left + right;
case TokenType.Minus: return left - right;
case TokenType.Star: return left * right;
case TokenType.Slash:
if (right == 0) {
throw new RangeError('Division by zero');
}
return left / right;
default:
throw new ParseError('Not a binary operator', node.position);
}
}
} // Exhaustive over Node's three subclasses: no default, no trailing return
}
try {
evaluate(parse('1 + 2 * (3 - 4) / -2')); // 2
evaluate(parse('1 + '));
} catch (e: ParseError) {
console.error(e.message, e.position); // Typed catch selects parse failures
} catch (e: RangeError) {
console.error('math error', e.message);
}
Coverage Notes
Sum types are the feature every comparison language leads with, and the proposal now expresses them directly. Most of what earlier drafts recorded as gaps here has since been specified; these notes track what's resolved and the one pattern left deliberately to nominal typing:
- Closed hierarchies - resolved.
Nodeissealed, so the evaluator'sswitchover its subclasses is checked exhaustive: with nodefault, adding a node kind is a compile-time error until every evaluator handles it, the same totality Rust's enum and Java's sealed interface give. There is noUnknown node kindfallthrow because the compiler proves there is no fourth kind. The main proposal demonstrates the sealed-class switch on this very hierarchy. - Recursive types - resolved. The classes are nominal and hoisted, so
operand: Noderefers back to the sealed base without ceremony. The union spellingtype Node = NumberNode | UnaryNode | BinaryNodeis equally valid now (a union of classes is a reference), and the main proposal's structural-identity rule handles the structural case coinductively, terminating each cycle at its reference position. The sealed class form used here is the closed one; a bare union is the open one. - Narrowing composition.
instanceofnarrows (per its README section), and the enumswitchnarrows, but narrowing on a field (if (node.op == TokenType.Plus)) discriminating the object holding it is the TypeScript-style pattern this proposal has no rule for, and with nominal classes it doesn't need one - worth saying explicitly so it isn't assumed. - Enum-to-string in errors - resolved.
TokenType.toString(current.type)gives the enumerator's key for messages. The enum section now also lets astring-underlying enum interpolate as its value directly; token kinds here are auint8enum, whose interpolation is the underlying number, sotoStringis the right call for a readable name. :=for typed construction - resolved.{ ... } := BinaryNodein areturnis blessed by the typed-assignment section, which uses this same construct-and-return shape; it fills the class layout from the literal without running a constructor.- Generator plus lookahead - resolved.
tokens.next().valuetypes asTokenbecausetokenize's yield and return types coincide; the iteration section specifiesGenerator.<Y, R, N>and thatnext()returns{ value: Y | R, done: boolean }, narrowing ondone.