Token stream being parsed into an AST

Making a Language: The Parser

Building a recursive descent parser with precedence climbing for Spark's expressions, statements, and control flow.

May 18, 2026#typescript#compilers

In the previous part, we built the lexer that turns source code into a flat list of tokens. Now we need to give those tokens structure.

The parser takes the token list and builds an Abstract Syntax Tree (AST), a tree representation of the program's grammar. I use recursive descent parsing with precedence climbing for expressions. Each grammar rule becomes a function, making the code map directly to the language spec.

Entry Point

parser.ts
1export function parse(tokens: Token[]): Program {
2  const p = new Parser(tokens);
3  const body: Statement[] = [];
4
5  while (!p.atEnd()) {
6    const stmt = p.parseStatement();
7    if (stmt) body.push(stmt);
8  }
9
10  return { kind: "Program", body };
11}

The entry point creates a Parser instance, then loops through every token calling parseStatement(). Each call consumes tokens and returns an AST node. The result is a Program node containing the flat list of top-level statements.

Parser Helpers

parser.ts
1private peek(): Token {
2  return this.tokens[this.pos];
3}
4
5private previous(): Token {
6  return this.tokens[this.pos - 1];
7}
8
9public atEnd(): boolean {
10  return this.peek().type === TokenType.EOF;
11}
12
13private advance(): Token {
14  if (!this.atEnd()) this.pos++;
15  return this.previous();
16}
17
18private check(type: TokenType): boolean {
19  if (this.atEnd()) return false;
20  return this.peek().type === type;
21}
22
23private match(...types: TokenType[]): Token | null {
24  for (const type of types) {
25    if (this.check(type)) {
26      return this.advance();
27    }
28  }
29  return null;
30}
31
32private consume(type: TokenType, message: string): Token {
33  if (this.check(type)) return this.advance();
34  const t = this.peek();
35  throw new ParseError(`${message}, got "${t.value}"`, t.line, t.col);
36}

These are the primitives every recursive descent parser needs. match checks the current token against expected types and advances if it matches, returning null otherwise. consume does the same but throws a ParseError with the exact line and column. That is how the parser generates human-readable error messages.

Statement Dispatch

parser.ts
1public parseStatement(): Statement | null {
2  if (this.match(TokenType.Let)) return this.parseLet();
3  if (this.match(TokenType.If)) return this.parseIf();
4  if (this.match(TokenType.Fn)) return this.parseFunction();
5  if (this.match(TokenType.Return)) return this.parseReturn();
6  if (this.match(TokenType.Print)) return this.parsePrint();
7  if (this.atEnd()) return null;
8  return this.parseExpressionStatement();
9}

parseStatement looks at the current token and dispatches to the right sub-parser. The key insight is that match both checks and consumes the keyword token, so by the time parseLet runs the val keyword is already consumed and the parser is positioned at the variable name.

Parsing Each Statement

parser.ts
1private parseLet(): LetStatement {
2  const name = this.consume(
3    TokenType.Identifier,
4    "Expected variable name after val",
5  );
6  this.consume(TokenType.Eq, "Expected '=' after variable name");
7  const value = this.parseExpression(0);
8  return { kind: "LetStatement", name: name.value, value };
9}

parseLet consumes the variable name and =, then delegates to parseExpression for the value. It returns a LetStatement node with the name and initializer expression.

parser.ts
1private parseIf(): IfStatement {
2  this.consume(TokenType.LParen, "Expected '(' after when");
3  const condition = this.parseExpression(0);
4  this.consume(TokenType.RParen, "Expected ')' after when condition");
5  this.consume(TokenType.LBrace, "Expected '{' before when body");
6  const consequent = this.parseBlock();
7  let alternate: Statement[] | null = null;
8  if (this.match(TokenType.Else)) {
9    if (this.match(TokenType.If)) {
10      const inner = this.parseIf();
11      alternate = [inner];
12    } else {
13      this.consume(TokenType.LBrace, "Expected '{' after else");
14      alternate = this.parseBlock();
15    }
16  }
17  return { kind: "IfStatement", condition, consequent, alternate };
18}

parseIf handles the full when (cond) { ... } else { ... } grammar. The else when case is handled by recursively calling parseIf. This gives us chainable else-if without any special syntax. The block bodies are parsed by parseBlock, which collects statements until it hits a closing }.

parser.ts
1private parseFunction(): FunctionDeclaration {
2  const name = this.consume(
3    TokenType.Identifier,
4    "Expected function name after func",
5  );
6  this.consume(TokenType.LParen, "Expected '(' after function name");
7  const params: string[] = [];
8  if (!this.check(TokenType.RParen)) {
9    params.push(
10      this.consume(TokenType.Identifier, "Expected parameter name").value,
11    );
12    while (this.match(TokenType.Comma)) {
13      params.push(
14        this.consume(TokenType.Identifier, "Expected parameter name").value,
15      );
16    }
17  }
18  this.consume(TokenType.RParen, "Expected ')' after parameters");
19  this.consume(TokenType.LBrace, "Expected '{' before function body");
20  const body = this.parseBlock();
21  return { kind: "FunctionDeclaration", name: name.value, params, body };
22}

Functions follow the standard pattern: name, parenthesized parameter list (wrapped in a comma loop), then a block body. Parameter names are collected as strings. Type annotations could be added here later by consuming a : and a type token after each parameter name.

Expression Parsing (Precedence Climbing)

parser.ts
1private parseExpression(precedence: number): Expression {
2  let left = this.parsePrefix();
3
4  while (!this.atEnd() && precedence < this.getPrecedence(this.peek().type)) {
5    const op = this.advance().value;
6    const right = this.parseExpression(this.getPrecedenceFromOp(op));
7    left = {
8      kind: "BinaryExpression",
9      left,
10      operator: op,
11      right,
12    } as BinaryExpression;
13  }
14
15  return left;
16}

This is the core of the precedence climbing (or Pratt parsing) approach. It starts by parsing a prefix expression (literal, variable, parenthesized expression, unary operator), then loops: while the next operator has higher precedence than the current minimum, it consumes the operator and recursively parses the right operand with a higher minimum precedence. This is what makes 3 + 4 * 2 parse as 3 + (4 * 2). When parsing 4 * 2, the * has precedence 5 which is higher than +'s 4, so the loop continues and groups them together.

parser.ts
1private parsePrefix(): Expression {
2  if (this.match(TokenType.Number)) {
3    return {
4      kind: "NumberLiteral",
5      value: parseFloat(this.previous().value),
6    } as NumberLiteral;
7  }
8  if (this.match(TokenType.String)) {
9    return {
10      kind: "StringLiteral",
11      value: this.previous().value,
12    } as StringLiteral;
13  }
14  if (this.match(TokenType.True)) {
15    return { kind: "BoolLiteral", value: true } as BoolLiteral;
16  }
17  if (this.match(TokenType.False)) {
18    return { kind: "BoolLiteral", value: false } as BoolLiteral;
19  }
20  if (this.match(TokenType.LParen)) {
21    const expr = this.parseExpression(0);
22    this.consume(TokenType.RParen, "Expected ')' after expression");
23    return expr;
24  }
25  if (this.match(TokenType.LBracket)) {
26    return this.parseArray();
27  }
28  if (this.match(TokenType.Minus)) {
29    const right = this.parseExpression(10);
30    return {
31      kind: "BinaryExpression",
32      left: { kind: "NumberLiteral", value: 0 } as NumberLiteral,
33      operator: "-",
34      right,
35    } as BinaryExpression;
36  }
37  if (this.match(TokenType.Bang)) {
38    const right = this.parseExpression(10);
39    return {
40      kind: "BinaryExpression",
41      left: right,
42      operator: "!",
43      right: { kind: "BoolLiteral", value: false } as BoolLiteral,
44    } as unknown as Expression;
45  }
46
47  if (this.match(TokenType.Identifier)) {
48    const name = this.previous().value;
49    return this.parseIdentifierSuffix({
50      kind: "Identifier",
51      name,
52    } as Identifier);
53  }
54
55  const t = this.peek();
56  throw new ParseError(`Unexpected token "${t.value}"`, t.line, t.col);
57}

parsePrefix handles every expression that can appear on the left side of an operator. Literals (numbers, strings, booleans) and parenthesized sub-expressions are straightforward. Unary - and ! are desugared into binary expressions: negation becomes 0 - x, and logical not becomes x != true. Identifiers are passed to parseIdentifierSuffix to handle chaining.

parser.ts
1private parseIdentifierSuffix(left: Expression): Expression {
2  if (this.match(TokenType.Eq)) {
3    if (left.kind !== "Identifier") {
4      const t = this.previous();
5      throw new ParseError("Cannot assign to non-identifier", t.line, t.col);
6    }
7    const value = this.parseExpression(0);
8    return {
9      kind: "Assignment",
10      name: (left as Identifier).name,
11      value,
12    } as Assignment;
13  }
14  if (this.check(TokenType.LParen)) {
15    return this.parseCallSuffix(left);
16  }
17  if (this.check(TokenType.LBracket)) {
18    return this.parseIndexSuffix(left);
19  }
20  return left;
21}
22
23private parseCallSuffix(callee: Expression): Expression {
24  this.advance();
25  const args: Expression[] = [];
26  if (!this.check(TokenType.RParen)) {
27    args.push(this.parseExpression(0));
28    while (this.match(TokenType.Comma)) {
29      args.push(this.parseExpression(0));
30    }
31  }
32  this.consume(TokenType.RParen, "Expected ')' after arguments");
33  const expr: Expression = { kind: "CallExpression", callee, args };
34  if (this.check(TokenType.LParen)) return this.parseCallSuffix(expr);
35  if (this.check(TokenType.LBracket)) return this.parseIndexSuffix(expr);
36  return expr;
37}
38
39private parseIndexSuffix(array: Expression): Expression {
40  this.advance();
41  const index = this.parseExpression(0);
42  this.consume(TokenType.RBracket, "Expected ']' after index");
43  const expr: Expression = { kind: "IndexExpression", array, index };
44  if (this.check(TokenType.LParen)) return this.parseCallSuffix(expr);
45  if (this.check(TokenType.LBracket)) return this.parseIndexSuffix(expr);
46  return expr;
47}

parseIdentifierSuffix handles the three things you can do after a name: assign to it (x = 5), call it (foo()), or index it (arr[0]). The call and index suffix methods are also the mechanism for chaining. foo()[0].bar() parses correctly because each suffix method checks if another .() or [] follows and recursively wraps the result.

Precedence Table

parser.ts
1private getPrecedence(type: TokenType): number {
2  switch (type) {
3    case TokenType.DotDot:
4      return 1;
5    case TokenType.EqEq:
6    case TokenType.BangEq:
7      return 2;
8    case TokenType.Lt:
9    case TokenType.Gt:
10    case TokenType.LtEq:
11    case TokenType.GtEq:
12      return 3;
13    case TokenType.Plus:
14    case TokenType.Minus:
15      return 4;
16    case TokenType.Star:
17    case TokenType.Slash:
18      return 5;
19    case TokenType.LParen:
20    case TokenType.LBracket:
21      return 7;
22    default:
23      return 0;
24  }
25}
26
27private getPrecedenceFromOp(op: string): number {
28  const map: Record<string, number> = {
29    "..": 1,
30    "==": 2,
31    "!=": 2,
32    "<": 3,
33    ">": 3,
34    "<=": 3,
35    ">=": 3,
36    "+": 4,
37    "-": 4,
38    "*": 5,
39    "/": 5,
40  };
41  return map[op] ?? 0;
42}

The precedence table is split into two lookup methods. getPrecedence maps token types (used by parseExpression to decide whether to loop) and getPrecedenceFromOp maps operator strings (used to determine the minimum precedence for the right operand). Both use the same numeric values, with call/bracket at 7 acting as the highest binding power.

Operators Precedence
.. 1 (range)
== != 2 (comparison)
< > <= >= 3 (relational)
+ - 4 (addition)
* / 5 (multiplication)
() [] 7 (call/index)

In the next part, we'll build the evaluator, the component that actually walks the AST and produces output.