Viscosity
Review
Part III · A tree-walking interpreter · Day 8

31. Syntax trees

Tokens come in a flat line, but a program’s meaning is nested: this operation inside that one, inside a statement. The parser’s job is to build that nesting as a tree. Here is the tree Drip uses, and a ten-line function that prints it so you can see it.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Explain what an abstract syntax tree keeps from the source, and what it leaves out.
  • Read Drip’s Expr and Stmt types as discriminated unions of AST nodes.
  • Tell an expression from a statement.
  • Write a tree printer that turns an AST back into fully bracketed text.

A flat line of tokens

The scanner turns -2 * (3 + 4) into a flat row of tokens:

-  2  *  (  3  +  4  )

But that is not how you read it. You see a multiplication, whose left side is “minus 2”, and whose right side is a whole addition. The meaning is nested, one operation inside another. Before Drip can run this, it needs that nesting written down somewhere. The natural shape is a tree, like the ones you walked in chapter 9.

Which tree?

When you derived a sentence in chapter 30, every rule you used replaced one nonterminal with its parts. Draw each replacement as a node with children, and you get a tree of the whole derivation, called a parse tree. It is faithful but cluttered: it has a node for every rule and every token, including the ;.

Intuition

A recipe card says “whisk: eggs, sugar”. It doesn’t keep the chatty sentences, the commas or the page layout of the cookbook it came from. It keeps exactly what you need to cook. An abstract syntax tree is the recipe card of a program.

Above: the parse tree of print 1 + 2; with a node for every rule used, ten nodes in all. Below: the abstract syntax tree of the same statement, with four nodes: a print node holding a binary plus node, whose left and right children are the literals 1 and 2.PARSE TREE (every rule, every token)stmt"print"expr";"expr"+"itemitemNUMBER 2NUMBER 1ABSTRACT SYNTAX TREE (only what matters)expressionleftrightkind: 'print''binary' op +'literal' 1'literal' 2
The AST keeps the meaning and drops the scaffolding. print 1 + 2; twice. Above, the parse tree from chapter 30’s grammar: a node for every rule (dashed) and every token. Below, the abstract syntax tree the Drip parser builds: four nodes. The semicolon, the helper rules and the word print as a token are gone; what is left says “print the sum of 1 and 2”.
Definition

An abstract syntax tree (AST) is a program stored as nested nodes, keeping only what affects its meaning. Each AST node is one construct: a kind (such as binary, literal or print) plus its children, the smaller nodes and tokens it is made of.

Nodes as a discriminated union

You built a tree type like this in chapter 14. Drip’s is the same idea, with more kinds. Here are the first few, from companion/ts/src/ast.ts:

type Expr =
  | { kind: 'literal';
      value: number | string
           | boolean | null }
  | { kind: 'grouping'; expression: Expr }
  | { kind: 'unary';
      operator: Token; right: Expr }
  | { kind: 'binary'; left: Expr;
      operator: Token; right: Expr }
  | { kind: 'variable'; name: Token };
  // ...and seven more kinds

Every node is a plain object with a kind tag. Checking e.kind narrows e to one variant, so the checker knows a binary node has a left. Operators and names are kept as whole tokens, not just strings, so that a later error message can say which line they came from.

A literal’s value is what the scanner put in the token’s literal field: a number or a string. The keywords true, false and nil become literals too, holding true, false and null.

There is one kind that isn’t strictly needed: grouping, for brackets. The tree’s shape already says that 3 + 4 happens first. Drip keeps the node anyway, so a printed tree shows where the brackets were.

Try itBuilding a tree by hand
const lit = (value) =>
  ({ kind: 'literal', value });
const bin = (left, op, right) => ({
  kind: 'binary', left,
  operator: { lexeme: op }, right,
});

// 1 + 2 * 3, as the parser will
// build it: * is deeper, so it
// happens first.
const e = bin(lit(1), '+',
  bin(lit(2), '*', lit(3)));

console.log(e.kind);
console.log(e.left.value);
console.log(e.right.operator.lexeme);
console.log(e.right.right.value);

// Try: build (1 + 2) * 3 instead.

Expressions and statements

Drip has two families of node. An expression produces a value: 1 + 2, x, "hi". A statement does something: prints, declares a variable, loops. Statements get their own union:

type Stmt =
  | { kind: 'expression'; expression: Expr }
  | { kind: 'print'; expression: Expr }
  | { kind: 'let'; name: Token;
      initializer: Expr | null }
  | { kind: 'block'; statements: Stmt[] }
  | { kind: 'if'; condition: Expr;
      then: Stmt; else: Stmt | null }
  // ...while, function, return, class

Statements contain expressions (a print holds the expression to print), and some contain other statements (a block holds a list). A whole Drip program parses to an array of statements, Stmt[].

Definition

An expression is code that produces a value. A statement is code that is run for its effect, such as printing or declaring a variable. An expression statement is an expression followed by ;, run for its effect with the value thrown away.

Printing a tree

A tree is hard to look at in the console. JSON.stringify works, but 1 + 2 alone is about 150 characters of braces. So Drip comes with a tree printer that writes each node as brackets, the operator first:

function printExpr(e: Expr): string {
  switch (e.kind) {
    case 'literal':
      return e.value === null
        ? 'nil' : String(e.value);
    case 'grouping':
      return '(group ' +
        printExpr(e.expression) + ')';
    case 'binary':
      return '(' + e.operator.lexeme +
        ' ' + printExpr(e.left) +
        ' ' + printExpr(e.right) + ')';
    // ...a case for every kind
  }
}

(A switch jumps to the case whose value equals e.kind.) The real printExpr in ast.ts has a case for all twelve kinds, and no default. Add a thirteenth kind without a case, and tsc reports that the function can end without returning a string: an exhaustiveness check for free.

Step throughPrinting a tree
function printExpr(e) {
switch (e.kind) {
case 'literal':
return String(e.value);
case 'grouping':
return '(group ' +
printExpr(e.expression) + ')';
case 'unary':
return '(' + e.operator.lexeme +
' ' + printExpr(e.right) + ')';
case 'binary':
return '(' + e.operator.lexeme +
' ' + printExpr(e.left) +
' ' + printExpr(e.right) + ')';
}
}
The tree of -2 * (3 + 4). Each node is marked as waiting, active or done, with the string it returned.binary *unary -groupingliteral 2binary +literal 3literal 4RESULT…not yetcalls on the stack: 0
The tree of -2 * (3 + 4). printExpr will turn it back into text, one node at a time.
1/12
Definition

A tree printer turns a tree back into text so you can check it. Drip’s prints every node as (operator children...): the brackets show the tree’s shape exactly, with no precedence rules to remember. So -2 * (3 + 4) prints as (* (- 2) (group (+ 3 4))).

The printer isn’t meant to reproduce the source. "hi" + name prints as (+ hi name), without quotes. Its job is to make a tree’s shape easy to compare with what you expected, which is exactly what a parser test needs:

test('* binds tighter than +', () => {
  expect(show('1 + 2 * 3'))
    .toBe('(+ 1 (* 2 3))');
});

Here show would scan, parse and print. That test is one line of expected text instead of a nest of objects.

Under the hood

Most language tools are built around an AST. ESTree, the tree format many JavaScript tools share, describes every node as an object with a string type field, the same tagging idea as Drip’s kind[1]. In the companion, the AST is the meeting point of the whole interpreter: the parser builds Expr and Stmt nodes, the resolver (chapter 42) walks them to work out where each variable lives, and the interpreter walks them to run the program. Drip’s node kinds follow Lox’s, from Crafting Interpreters[2].

Your turn

ExerciseWrite the tree printer

Write printExpr(e) for four kinds of node: literal (value), grouping (expression), unary (operator, right) and binary (left, operator, right). Print like Drip: a literal as its value (null as nil), and the others as (group x), (op x) and (op x y), using operator.lexeme. Throw an Error for any other kind.

function printExpr(e) {
  // your code here
}

// -2 * (3 + 4)
const lit = (value) =>
  ({ kind: 'literal', value });
const op = (lexeme) => ({ lexeme });
const t = {
  kind: 'binary', operator: op('*'),
  left: {
    kind: 'unary', operator: op('-'),
    right: lit(2),
  },
  right: {
    kind: 'grouping',
    expression: {
      kind: 'binary', operator: op('+'),
      left: lit(3), right: lit(4),
    },
  },
};
console.log(printExpr(t));
Extra practiceEvaluate the tree

Write evaluate(e) for the same four kinds. A literal gives its value; a grouping gives its inner expression’s value; a unary node (always - here) negates; a binary node applies +, -, * or / to its children’s values. Throw an Error for anything else.

function evaluate(e) {
  // your code here
}

const t = {
  kind: 'binary',
  operator: { lexeme: '*' },
  left: { kind: 'literal', value: 6 },
  right: { kind: 'literal', value: 7 },
};
console.log(evaluate(t));

What you learned

  • An AST stores a program as nested nodes, keeping what affects its meaning and dropping punctuation and helper rules.
  • Each AST node is a kind plus its children. Drip’s Expr and Stmt are discriminated unions tagged by kind.
  • Expressions produce values; statements do things. A program is an array of statements.
  • A tree printer writes each node as (operator children...), making a tree easy to check and to test.

Next: the parser that builds these trees from tokens.

Check your understanding

0/6
  1. 1.

    Which statement about the AST of print (1 + 2); is true?

  2. 2.

    In Drip’s Expr type, what makes it safe to read e.right after if (e.kind === 'unary')?

  3. 3.

    Which of these is a statement in Drip, not an expression?

  4. 4.

    What does Drip’s printExpr print for the tree of -x * 3?

  5. 5.

    Why is a tree printer useful when testing a parser?

  6. 6.

    printExpr is called on a binary node whose children are both binary nodes. In what order are the nodes finished?

References

  1. [1] ESTree contributors. The ESTree Spec (es5.md: Node objects). github.com/estree/estree. github.com/estree/estree/blob/master/es5.md (checked 2026-10-31)
  2. [2] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)