37. Environments and scope
One big table of variables cannot tell an inner a from an outer a. Give every block its own small table with an arrow to its parent, and Drip gets real scope.
- Explain why a single global table breaks block scope.
- Build an environment with define, get and assign, where get and assign walk the parent chain.
- Trace a lookup through the scope chain, and say which variable a shadowed name means.
- Explain why executeBlock restores the old environment in a finally block.
Two variables, one name
At the end of the last chapter, every Drip variable lived in one table:
the globals. let wrote a name into it, and using the name read it back.
That works until someone writes a block:
let a = "global";
{
let a = "block";
print a;
}
print a;
Drip, like JavaScript, has block scope (chapter 6), so this should print
block and then global. The inner a is a different variable that
happens to share a name, and it should vanish at the closing }. With
one table, the second let simply overwrites the first, and the program
prints block twice. The outer a is lost for good.
One table per program is not enough. You need one table per block, and a way for the tables to find each other.
Environments
Think of a stack of notepads, one per block you are inside. Each new pad is tied by a string to the pad that was open when it was made. To write a new name, you always use the pad in front of you. To find a name, you check that pad, and if it isn’t there, you follow the string to the next pad, and the next.
That picture is a small class. Here is the heart of Environment from
the companion’s runtime.ts, slightly simplified:
class Environment {
values = new Map<string, Value>();
enclosing: Environment | null;
constructor(
enclosing: Environment | null = null,
) {
this.enclosing = enclosing;
}
define(name: string, v: Value) {
this.values.set(name, v);
}
}
The Map (chapter 8) holds this block’s names and their runtime values.
enclosing is a reference to the parent environment, or null for the
globals, which have no parent. define only ever touches this table:
it is what let calls. If the name is already here, it is replaced,
which is fine at the top level, where Drip lets you redefine a global.
(Declaring the same name twice in one block is rejected before the
program runs, by a pass you’ll write in chapter 42.)
An environment is a table from names to values, plus a reference to
an enclosing (parent) environment. The interpreter always has one
current environment. let defines a name in the current
environment and never looks anywhere else.
Looking a name up
Reading a variable is where the parent arrow earns its keep:
get(name: string): Value {
if (this.values.has(name)) {
return this.values.get(name)!;
}
if (this.enclosing) {
return this.enclosing.get(name);
}
throw new RuntimeError(
`Undefined variable '${name}'.`);
}
Check here. If that fails, ask the parent, which checks its table and
asks its parent: a recursive walk (chapter 9) along a chain of
references. Only when the walk falls off the end, past the globals, is
the name truly undefined, and that is a runtime error. (The real get
takes the name’s token rather than a string, so the error can say which
line it came from.)
Step through the program below. Watch where each let writes, and which
boxes each lookup checks:
let a = "global";let b = "also";{let a = "outer";{let a = "inner";print a;print b;}print a;}print a;
The scope chain is the line of environments from the current one, through each parent, to the globals. Looking a name up walks the chain and stops at the first environment that has it. If none does, the lookup fails with a runtime error. When an inner environment defines a name that an outer one also has, the inner variable shadows the outer one: inside that block, the outer variable can’t be reached by name at all.
Because each block’s environment is made while the interpreter is inside
the enclosing block, the chain always mirrors how the blocks are nested
in the source text. It is chapter 6’s rooms with one-way glass, built
out of Maps and arrows. MDN describes JavaScript’s own scopes the same
way, as a chain of outer scopes that inner code can see[2].
let x = "global";
{
let x = "outer";
{
x = "changed";
let y = x;
print y;
}
print x;
}
print x;Assigning: walk, then write
n = 2 must change an existing variable, wherever it lives. So
assign walks the chain just like get, and writes into the first
table that already has the name:
assign(name: string, v: Value) {
if (this.values.has(name)) {
this.values.set(name, v);
return;
}
if (this.enclosing) {
this.enclosing.assign(name, v);
return;
}
throw new RuntimeError(
`Undefined variable '${name}'.`);
}
Notice that assign never creates a variable. Assigning to a name that
nobody declared is an error, not a quiet new global.
let and = look alike but do opposite things to the chain. Inside a
block, n = 2; walks outward and changes the outer n. let n = 2;
makes a new n right here, which shadows the outer one and disappears
at the }. The outer n is untouched.
let n = 1;
{ n = 2; }
print n; // 2
{ let n = 3; }
print n; // still 2
Entering and leaving a block
The interpreter keeps the current environment in a field, this.env.
A block statement makes a fresh environment whose parent is the current
one, and runs its statements inside it:
case 'block':
this.executeBlock(s.statements,
new Environment(this.env));
return;
executeBlock(stmts: Stmt[],
env: Environment) {
const previous = this.env;
try {
this.env = env;
for (const s of stmts) {
this.execute(s);
}
} finally {
this.env = previous;
}
}
Leaving the block is just switching back to previous. The block’s own
environment isn’t destroyed by hand: once nothing refers to it, it is
garbage like any other unreachable object.
Why finally? Because a block doesn’t always end at its }. A runtime
error halfway through throws straight out of execute. Without the
finally, this.env would be left pointing at the dead inner
environment. In chapter 40 you’ll see a second, more everyday reason:
return.
Every variable read here is a Map lookup, repeated once per
environment on the way up the chain. A variable used inside five
nested blocks but declared at the top costs six lookups, every time
the line runs. Chapter 42 fixes the worst of this: a resolver works
out before running how many hops up each variable lives, and the
interpreter jumps straight there with getAt(distance, name). That
is why the companion’s interpreter.ts reads variables through a
lookUp helper instead of calling get directly. The bytecode VM of
chapter 54 goes further and keeps local variables in numbered stack
slots, with no names left at run time at all. This design of an
environment as a table with a parent link follows Crafting
Interpreters, which Drip is based on[1].
Your turn
The core exercise builds the whole class. The extra one is in Drip.
Finish the Environment class. define(name, v) stores a value in this environment. get(name) returns the value from the nearest environment that has the name, checking this one first and then each parent. assign(name, v) changes the value in the nearest environment that has the name. If no environment in the chain has the name, get and assign must throw an Error whose message is Undefined variable 'NAME'. (with the real name).
class Environment {
constructor(parent = null) {
this.values = new Map();
this.parent = parent;
}
define(name, v) {
// your code here
}
get(name) {
// your code here
}
assign(name, v) {
// your code here
}
}
const g = new Environment();
g.define('a', 1);
const inner = new Environment(g);
console.log(inner.get('a'));This Drip program prints inner twice, because the block changes the outer x. Change one line so that the block gets its own x: the program should print inner and then outer.
let x = "outer";
{
x = "inner";
print x;
}
print x;What you learned
- An environment is a table from names to values with a reference to its enclosing environment. The globals have no parent.
letdefines in the current environment only.getandassignwalk the scope chain outward and stop at the first match. Falling off the end is a runtime error.- An inner variable shadows an outer one with the same name, until its block ends.
- Each block runs in a fresh environment, and
executeBlockrestores the previous one infinally, even if something is thrown.
Next: if, while and for, and why for never reaches the
interpreter at all.
Check your understanding
0/6- 1.
What does this Drip program print?
let a = 1; { let a = 2; print a; } print a; - 2.
What does this print?
let n = 1; { n = n + 1; } print n; - 3.
Which environment does
definewrite to? - 4.
A lookup for
bstarts in block 2, whose parent is block 1, whose parent is the globals. Only the globals haveb. How many tables are checked? - 5.
Why does
executeBlockusetry { ... } finally { this.env = previous; }? - 6.
In the program
{ let z = 1; } print z;what happens?
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. Closures (JavaScript Guide). MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Closures (checked 2026-10-31)