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.
- 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
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.
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:
fn fib(n) {if (n < 2) return n;return fib(n - 1) + fib(n - 2);}print fib(3);
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.
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 };
}
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.
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
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
}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
returncan sit deep inside loops, blocks and ifs, and must leave them all in one move: return unwinding.- Drip throws a
ReturnSignalholding the value, andexecuteFunctionBodycatches it, returns the value, and rethrows anything else.executeBlock’sfinallyrestores environments on the way out. No return meansnil. - 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.
What does this print?
fn f() { print "a"; return; print "b"; } print f(); - 2.
In the tree-walker, what does
case 'return'inexecutedo? - 3.
A runtime error is thrown inside a Drip function. What does
executeFunctionBodydo with it? - 4.
While a
ReturnSignalunwinds out of a function body, why does the caller end up back in its own environment? - 5.
Where does the tree-walker keep track of which Drip calls are waiting for which?
- 6.
What happens when a Drip function recurses with no base case?
References
- [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] 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] MDN contributors. Call stack (Glossary). MDN Web Docs. developer.mozilla.org/en-US/docs/Glossary/Call_stack (checked 2026-10-31)