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

40. Return and the call stack

A return statement can sit deep inside loops and ifs, yet it has to leave all of them at once. The interpreter does it by throwing, and in doing so it quietly hands Drip’s call stack to TypeScript.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Explain why return has to escape from several nested execute calls at once.
  • Implement return by throwing a signal and catching it at the edge of the call.
  • Trace a recursive Drip call on the host stack, and say what each host frame is doing.
  • Explain what the interpreter gains and loses by reusing its host language’s call stack.

A way out from deep inside

Drip functions can do work but can’t hand back an answer. They need return. Here is a function that finds the first number whose square is bigger than a limit:

fn find(limit) {
  let i = 0;
  while (true) {
    if (i * i > limit) return i;
    i = i + 1;
  }
}
print find(50); // 8

Think about where the interpreter is when it reaches return i. It is not in one place. It is inside a whole pile of its own TypeScript calls:

executeFunctionBody   the call of find
  executeBlock        find's body
    execute(while)
      execute(block)  the loop body
        executeBlock
          execute(if)
            execute(return)

Each of these is a TypeScript function in the middle of its own work. execute(while) is half-way round a while loop that, on its own, would go round forever. return has to abandon all of them at once and land back at the call, carrying i.

Two ways out

Intuition

You’re in a tall office building, and you need to get a message to the lobby. You could hand a note to someone on each floor, and every person on the way down has to stop, read it and pass it on. Or you could pull a fire alarm: everyone between you and the lobby simply stops, and the message goes straight down.

The note-passing way is possible. execute could return a value meaning “a return is happening”, and every caller would have to check it: the while loop, the block loop, the if. Forget the check in one place and return stops working there.

The fire-alarm way is an exception. Chapter 11 showed that a throw abandons every frame between it and the nearest matching catch. That is exactly the shape of return, so the companion’s interpreter.ts throws:

class ReturnSignal {
  constructor(readonly value: Value) {}
}

// in execute:
case 'return':
  throw new ReturnSignal(s.value
    ? this.evaluate(s.value) : null);

return; with no value, or falling off the end of the body, both give nil. ReturnSignal is a plain class, not an Error. It isn’t an error, and nothing needs a message from it. The catch sits at the edge of every function call:

executeFunctionBody(decl, env) {
  try {
    this.executeBlock(decl.body, env);
  } catch (e) {
    if (e instanceof ReturnSignal) {
      return e.value;
    }
    throw e;
  }
  return null;
}

Only a ReturnSignal is caught. A real RuntimeError is thrown on again with throw e, so it keeps travelling to the top and is reported.

Remember the finally in executeBlock, from chapter 37? This is its everyday job. Every block that the signal passes through switches the current environment back as it goes, so when the call ends, the caller is back in its own environment.

Definition

Return unwinding is leaving every statement between a return and its function call in one move. Drip’s interpreter throws a ReturnSignal carrying the return value. Each execute and executeBlock on the way is abandoned (running its finally blocks), and executeFunctionBody catches the signal and returns its value normally. Other exceptions pass through untouched.

Watching it in a recursive call

In fib, every call returns, and most calls make two more calls first. Step through fib(3). Each Drip call is drawn with the host frames it adds; the ReturnSignal is drawn in red as it passes through them:

Step throughreturn as a thrown signal, in fib(3)
fn fib(n) {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
print fib(3);
Drip calls stacked newest on top. Each holds four host frames: call, executeFunctionBody, executeBlock and execute. A ReturnSignal travels down through them until it is caught.DRIP CALLS AND THEIR HOST FRAMES (newest on top)top level: interpret · execute · evaluateinterpreter frames on the host stack: 3
The top level evaluates the call fib(3). Already three interpreter methods are running on the host stack: interpret, execute (the print) and evaluate (the call).
1/12

Each signal travels only as far as its own call’s catch. fib(3) never sees the throws inside fib(2): to it, fib(2) simply returned 1.

Borrowing TypeScript’s call stack

Look again at the step-through. Drip has a call stack: fib(3) waiting for fib(2) waiting for fib(1). But the interpreter never built one. There’s no array of Drip frames anywhere in interpreter.ts. Each Drip call is a chain of TypeScript calls (evaluate, call, executeFunctionBody, executeBlock, execute), so Drip’s stack is part of TypeScript’s stack. “Where do I go back to?” and “what was I in the middle of?” are answered by TypeScript’s frames. The Drip variables live separately, in Environment objects on the heap.

Definition

The host stack is the call stack[3] of the language an interpreter is written in. A tree-walking interpreter that runs a call by making ordinary host-language calls is reusing the host stack: every Drip call becomes several host frames, and returning from a Drip call is returning from those host calls.

That’s a great bargain for very little code. Recursion, mutual recursion and calls nested inside arguments all work for free. But three costs come with it.

Depth. Every Drip call level costs several host frames. In the step-through, three Drip calls already put twenty of the interpreter’s methods on the host stack: about six per level. So Drip runs out of stack long before an equivalent TypeScript program would. When the host stack is full, JavaScript throws a RangeError[2]. drip.ts catches that and reports it in Drip’s terms:

if (e instanceof RangeError) {
  stderr.push('Stack overflow.',
    '[line 0]');
  return { stdout, stderr,
           exitCode: 70 };
}
Try itRecursion with no base case
fn down(n) {
  return down(n + 1);
}

print "going down";
down(0);
print "never printed";

Visibility. Drip can’t look at its own stack, because the stack isn’t Drip data: it’s buried in the host’s frames. That makes features like pausing a Drip program, or listing its active calls in an error message, hard to add.

Speed. Each Drip call pays for several host calls, plus the Environment and its Map, plus a thrown object for every return.

Under the hood

Part IV’s bytecode VM pays all three costs back. In chapter 56 it keeps its own array of call frames, each one a small record of which function is running, where it is in that function’s bytecode, and where its slots start on a shared value stack. A Drip call pushes one record; return pops one. No exception is thrown, a Drip call costs no host frames at all, and the VM chooses its own depth limit, reporting Stack overflow. when a program exceeds it. The idea of implementing return with an exception in a tree-walker follows Crafting Interpreters[1].

Your turn

ExerciseCatch the return

exec runs one statement of a tiny language. A return statement throws a Ret holding its value, exactly like Drip's ReturnSignal. Write callFn(body, log): run each statement of the array body with exec(s, log). If a Ret is thrown, stop and return its value. If the body finishes without one, return null. Any other exception must keep travelling: don't swallow it.

class Ret {
  constructor(value) {
    this.value = value;
  }
}

// Runs one statement. Given: don't
// change it.
function exec(s, log) {
  switch (s.kind) {
    case 'log':
      log.push(s.text);
      return;
    case 'block':
      for (const t of s.body) {
        exec(t, log);
      }
      return;
    case 'return':
      throw new Ret(s.value);
    default:
      throw new Error('bad: ' + s.kind);
  }
}

function callFn(body, log) {
  // your code here
}
Extra practiceA recursive power function

Write a recursive Drip function pow(b, e) that returns b to the power e, for whole numbers e >= 0. Use the facts pow(b, 0) is 1 and pow(b, e) is b * pow(b, e - 1). The program should print 1024, 81 and 1.

fn pow(b, e) {
  // your code here
}

print pow(2, 10);
print pow(3, 4);
print pow(5, 0);

What you learned

  • return can sit deep inside loops, blocks and ifs, and must leave them all in one move: return unwinding.
  • Drip throws a ReturnSignal holding the value, and executeFunctionBody catches it, returns the value, and rethrows anything else. executeBlock’s finally restores environments on the way out. No return means nil.
  • The tree-walker reuses the host stack: each Drip call is a chain of TypeScript calls, so Drip’s call stack lives inside TypeScript’s.
  • That is simple and makes recursion free, but it limits depth (a deep Drip recursion becomes a host RangeError), hides the stack from the interpreter, and costs speed. The VM will keep its own frames.

Next: closures in Drip, and environments that outlive the call that made them.

Check your understanding

0/6
  1. 1.

    What does this print?

    fn f() {
      print "a";
      return;
      print "b";
    }
    print f();
  2. 2.

    In the tree-walker, what does case 'return' in execute do?

  3. 3.

    A runtime error is thrown inside a Drip function. What does executeFunctionBody do with it?

  4. 4.

    While a ReturnSignal unwinds out of a function body, why does the caller end up back in its own environment?

  5. 5.

    Where does the tree-walker keep track of which Drip calls are waiting for which?

  6. 6.

    What happens when a Drip function recurses with no base case?

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. InternalError: too much recursion. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Too_much_recursion (checked 2026-10-31)
  3. [3] MDN contributors. Call stack (Glossary). MDN Web Docs. developer.mozilla.org/en-US/docs/Glossary/Call_stack (checked 2026-10-31)