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

38. Control flow

Drip learns to choose and to repeat. Two of the three new statements are a few lines each, borrowed from TypeScript, and the third never reaches the interpreter at all.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Implement if and while in a tree-walking interpreter, using truthiness.
  • Desugar a for loop into a block, a while loop and another block, and explain each wrapper.
  • Explain why and and or are not ordinary binary operators, and predict the value they produce.

A program that can’t change its mind

Every Drip program so far runs straight down the page. Each statement runs exactly once, in order, and then the program ends. You can’t print the numbers 1 to 100 without writing 100 print statements, and you can’t do something only sometimes.

This chapter adds the three statements that fix that: if, while and for. Plus two operators, and and or, which turn out to be control flow in disguise.

Choosing: if

Intuition

The interpreter is a reader working through a recipe. An if is a step that says “if the oven is hot, do this; otherwise, do that”. The reader checks once, follows one branch, and never even reads the other.

The parser already turns if (c) A else B into a node with three children: condition, then and an optional else. Running it takes three lines of the companion’s interpreter.ts:

case 'if': {
  const c = this.evaluate(s.condition);
  if (isTruthy(c)) this.execute(s.then);
  else if (s.else) this.execute(s.else);
  return;
}

Evaluate the condition, ask whether it’s truthy with chapter 35’s isTruthy (only nil and false are falsey), and execute one of the branches. The other branch’s tree is never visited, so a runtime error hiding in it can’t happen.

Notice the trick: Drip’s if is implemented with TypeScript’s if. The interpreter makes a decision in its own language whenever the program it’s running makes one.

One parsing detail: in if (a) if (b) x; else y; the else belongs to the inner if. The inner ifStatement call parses its then-branch and then greedily takes any else that follows, before the outer one gets a look.

Repeating: while

while has the same shape, and borrows TypeScript’s while (chapter 5)[2]:

case 'while':
  while (isTruthy(
    this.evaluate(s.condition))) {
    this.execute(s.body);
  }
  return;

The condition is re-evaluated before every trip round the loop, so the body must change something the condition reads, or the loop never stops. (Drip has no break, so the condition is the only way out, for now.)

Definition

Control flow is the order in which a program’s statements run. if and while make that order depend on runtime values: which statement runs next is decided by evaluating a condition and testing its truthiness. A tree-walking interpreter implements each of them with the matching control flow of its host language.

The loop that isn’t there: for

A for loop packs four things into one line:

for (let i = 0; i < 3; i = i + 1)
  print i;

You could add a fifth kind of statement and a new case to the interpreter. But look at what it means: run the initializer once, then while the condition holds, run the body and then the increment. That is a while loop you already know how to run. So the parser rewrites it into one, and the interpreter never hears the word for:

Step throughTurning a for loop into a while loop
for (let i = 0; i < 3; i = i + 1)
print i;
The syntax tree the parser builds for a for loop: blocks and a while node around the four partsTHE TREE THE PARSER RETURNSlet i = 0;i < 3print i;i = i + 1;
The parser has read the four parts of the for loop: an initializer, a condition, an increment and a body. The AST has no "for" node kind to put them in.
1/5

Here is the end of forStatement in parser.ts, lightly simplified (block and exprStmt stand for the plain node objects the real code spells out):

let body = statement();
if (increment) {
  body = block([body,
    exprStmt(increment)]);
}
body = { kind: 'while',
         condition, body };
if (initializer) {
  body = block([initializer, body]);
}
return body;

Each part is optional. With no increment, the body is left alone; with no initializer, there’s no outer block. A missing condition, as in for (;;), becomes the literal true: a loop that runs forever.

The outer block matters. Because it gets its own environment (chapter 37), the loop’s i lives only as long as the loop, and it can shadow an i outside:

let i = "before";
for (let i = 0; i < 3; i = i + 1) {
  print i;
}
print i; // before
Definition

Desugaring means rewriting a construct in terms of simpler ones the language already has. The convenient form is syntactic sugar. Drip’s parser desugars for into a block containing the initializer and a while loop whose body is a block of the original body and the increment. Every pass that walks the tree afterwards (the interpreter, and the resolver of chapter 42) gets for loops for free.

Pitfall

Desugaring is only correct if the rewrite means exactly the same thing. Drip has no continue. If it did, this rewrite would break: a continue jumping to the end of the body would also skip the increment, which lives in the same block. Real languages with continue have to handle the increment more carefully.

and, or: operators that skip

and and or look like binary operators, and they sit in the Pratt table with binding powers like the rest (and binds tighter than or). But the binary case evaluates both sides before it looks at the operator. For these two, that’s wrong:

let ok = false and (calls = calls + 1);

Once the left side is false, the answer is known, and the assignment on the right must not happen. So the parser makes a separate logical node, and the interpreter treats it like a tiny if:

case 'logical': {
  const l = this.evaluate(e.left);
  if (e.operator.type === 'or') {
    return isTruthy(l)
      ? l : this.evaluate(e.right);
  }
  return isTruthy(l)
    ? this.evaluate(e.right) : l;
}

Look at what comes back: not true or false, but one of the operand values. nil or "default" is "default", and 1 and 2 is 2. JavaScript’s || and && behave the same way[3].

Try itand and or skip, and return values
let calls = 0;
let ok = false and (calls = calls + 1);
print ok;
print calls;

print nil or "default";
print 1 and 2 and 3;
print 0 or "zero is truthy";
Definition

Short-circuit evaluation means an operator skips evaluating its right operand when the left one already decides the result. In Drip, a or b returns a if it is truthy, and otherwise evaluates and returns b. a and b returns a if it is falsey, and otherwise evaluates and returns b. The skipped side’s side effects never happen.

Under the hood

The tree-walker gets its control flow for free by borrowing TypeScript’s if and while. The bytecode VM in Part IV can’t: it runs a flat list of instructions with no nesting at all. In chapter 55 its compiler turns if, while, for, and and or into jumps: instructions that say “if the value on top of the stack is falsey, continue at instruction 42”. Short-circuiting is then just a jump over the right operand’s code. The approach here, including desugaring for in the parser, follows Crafting Interpreters[1].

Your turn

The core exercise is the desugaring itself, in plain JavaScript.

ExerciseDesugar a for loop

Write desugarFor(f), the rewrite Drip's parser does. f has four parts: init (a statement or null), cond (an expression or null), incr (an expression or null) and body (a statement). Return the equivalent tree:

  • If there is an incr, the body becomes { kind: 'block', statements: [body, { kind: 'expression', expression: incr }] }.
  • Wrap that in { kind: 'while', condition, body }. A missing cond becomes { kind: 'literal', value: true }.
  • If there is an init, wrap everything in { kind: 'block', statements: [init, theWhile] }.
function desugarFor(f) {
  // f has init, cond, incr, body.
  // Return a block / while tree.
  return null;
}
Extra practicePowers of two

Write a Drip for loop that prints every power of two below 100, one per line, starting from 1: 1, 2, 4, ... 64.

// One for loop. Its increment
// doesn't have to add 1.

What you learned

  • Control flow is the order statements run in. if runs one branch and while re-checks its condition before every trip, both using truthiness, both borrowed from TypeScript.
  • Desugaring rewrites a construct into simpler ones. The parser turns for into { init; while (cond) { body; incr } }, and a missing condition becomes true.
  • The outer block keeps the loop variable local to the loop.
  • and and or short-circuit: they skip the right side when the left decides, and they return an operand’s value, not a boolean.

Next: functions, the last thing Drip needs before it can run real programs.

Check your understanding

0/6
  1. 1.

    What does this print?

    if (0) print "yes";
    else print "no";
  2. 2.

    Which tree does Drip’s parser build for for (let i = 0; i < 3; i = i + 1) print i;?

  3. 3.

    What does this print?

    let i = "out";
    for (let i = 0; i < 2; i = i + 1) {}
    print i;
  4. 4.

    print nil or false; What is printed?

  5. 5.

    After this runs, what is n?

    let n = 0;
    true or (n = 1);
    false and (n = 2);
  6. 6.

    Why does the tree-walker need hardly any code for if and while?

References

  1. [1] 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)
  2. [2] MDN contributors. while. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/while (checked 2026-10-31)
  3. [3] MDN contributors. Logical OR (||). MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_OR (checked 2026-10-31)