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

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.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • 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

Intuition

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.)

Definition

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:

Step throughThe environment chain, block by block
let a = "global";
let b = "also";
{
let a = "outer";
{
let a = "inner";
print a;
print b;
}
print a;
}
print a;
Environments as boxes. Each block's environment has an arrow to its parent. A lookup checks the current box, then follows the arrows.ENVIRONMENTS (arrow = parent)globals (current)a = "global"OUTPUT
The program starts with one environment: the globals. It has no parent. let a defines a in it.
1/12
Definition

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].

Try itAssignment finds the nearest variable
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.

Pitfall

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.

Under the hood

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.

ExerciseAn environment with a parent

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'));
Extra practiceKeep the outer variable

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.
  • let defines in the current environment only.
  • get and assign walk 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 executeBlock restores the previous one in finally, 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. 1.

    What does this Drip program print?

    let a = 1;
    {
      let a = 2;
      print a;
    }
    print a;
  2. 2.

    What does this print?

    let n = 1;
    {
      n = n + 1;
    }
    print n;
  3. 3.

    Which environment does define write to?

  4. 4.

    A lookup for b starts in block 2, whose parent is block 1, whose parent is the globals. Only the globals have b. How many tables are checked?

  5. 5.

    Why does executeBlock use try { ... } finally { this.env = previous; }?

  6. 6.

    In the program { let z = 1; } print z; what happens?

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. Closures (JavaScript Guide). MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Closures (checked 2026-10-31)