Part 3: Block Scope and the Two Pointers That Disagree
The JavaScript Environment Model — Part 3 of 4. By the end of this post you'll know exactly when the EC's two pointers diverge, why they were designed that way, and why "var is function-scoped, let is block-scoped" is descriptively true but mechanically useless.
Part 2 closed with a tease: the EC has two Environment Record pointers, LexicalEnvironment and VariableEnvironment, and they diverge in exactly one situation. This post is about that situation.
Here's the puzzle:
function f() {
if (true) {
var a = 1;
let b = 2;
}
console.log(a); // 1
console.log(b); // ReferenceError: b is not defined
}
f();
Both a and b are declared inside the same block. Both are initialized before the block ends. Yet a survives outside the block and b doesn't. That much you probably knew.
Most tutorials describe this as "var is function-scoped, let is block-scoped." The description is descriptively true. It will get the right answer on every test case. It is also mechanically useless — it doesn't explain why the engine treats them this way. It's a name for the behavior, not a model of the mechanism.
Now try this one:
function f() {
console.log(a); // undefined
console.log(b); // ReferenceError: b is not defined
if (true) {
var a = 1;
let b = 2;
}
}
f();
Both console.log calls run before the block has been entered. The var a and let b declaration lines haven't executed yet. And yet — different errors. a is already undefined at that point. b doesn't exist at all.
"var is function-scoped, let is block-scoped" doesn't help you predict this. Both declarations are inside the block. If the block is what scopes them, both should be invisible outside. But a is already visible before the block even runs.
This post explains why. By the end you'll know:
That the EC has two pointers to Environment Records, and the situation in which they diverge.
The four-step ritual the engine performs when entering a block.
Why
vardeclarations escape blocks: they target a pointer that doesn't move.The historical reason JavaScript was designed this way.
How
for-letloops give each iteration its own variable.The difference between strict-mode and sloppy-mode function declarations inside blocks.
How a function defined inside a block captures the block's record — and how that survives all the way through a four-level nested chain.
Section 1: The EC has two pointers to Environment Records
In Part 2 I named the EC's two pointers and asked you to read them as equivalent for the whole post. They stayed equal. Time to admit they don't always.
Every Execution Context has two fields that point at Environment Records:
┌──────────────────────────────────────┐
│ Execution Context │
├──────────────────────────────────────┤
│ LexicalEnvironment: → some record │
│ VariableEnvironment: → some record │
│ ...other bookkeeping... │
└──────────────────────────────────────┘
They serve different purposes:
LexicalEnvironmentis used for two things: wherelet,const, andclassdeclarations install their Bindings, and the starting point for every identifier lookup. When code reads or writes any variable, the engine starts atLexicalEnvironmentand walks the[[OuterEnv]]chain from there.VariableEnvironmentis used for one thing only: wherevardeclarations install their Bindings. It is never consulted for lookups.
At function entry, both pointers point at the same Environment Record — the fresh one the engine just created for the call. This was true throughout Part 2, which is why the simplification worked. The pointers stay equal until a specific event happens.
That event is: entering a block that contains at least one let, const, or class declaration.
Entering such a block causes the engine to perform a four-step ritual that creates a new Environment Record and changes one of the EC's pointers — but not the other. After this ritual, the two pointers point at different records, and var declarations inside the block end up in a different place than let/const declarations.
This pointer divergence is the entire mechanism behind var vs let scope behavior. There's no separate rule that says "var is function-scoped." There's a rule that says "var goes to VariableEnvironment," combined with the fact that VariableEnvironment doesn't change when you enter a block. The function-scoping behavior follows from the mechanism.
Section 2: What entering a block actually does
When the engine is executing your code and reaches a { that begins a block, it asks a single question: does this block contain any let, const, or class declarations? (Plus function declarations in strict mode — we'll cover this in Section 4.)
No → the braces are runtime no-ops. The engine treats them as syntax only.
vardeclarations inside install themselves the same way they would outside.Yes → the engine performs the four-step block-entry ritual.
The ritual:
Create a new Environment Record. Call it
blockRecord. It's an ordinary Environment Record — same structure as the function-level ones we've been working with. Abindingsmap and an[[OuterEnv]]pointer.Run a creation phase on the block's body. Find every
let,const, andclassdeclaration that is directly inside this block (not inside any nested block). For each, create a Binding inblockRecordwith value EMPTY.Set
blockRecord.[[OuterEnv]] = EC.LexicalEnvironment(whatever it currently is). The new block's parent in the scope chain is the previous lexical scope.Swap
EC.LexicalEnvironmentto point atblockRecord. Do not touchEC.VariableEnvironment.
After the ritual, the EC looks like this:
EC:
LexicalEnvironment → blockRecord (just swapped)
VariableEnvironment → f's record (unchanged)
blockRecord:
bindings: { (the block's let/const/class) }
[[OuterEnv]] → f's record
f's record:
bindings: { ... }
[[OuterEnv]] → outer scope
The two pointers have diverged. They point at different records.
When the engine reaches the closing } of the block, it performs the reverse:
Restore
EC.LexicalEnvironmentto what it was before block entry — which conveniently equalsblockRecord.[[OuterEnv]].blockRecordis no longer pointed at by any EC. If nothing else points at it (no inner function captured it), it becomes garbage. Its Bindings die with it.
Let's trace this with the opening puzzle:
function f() {
if (true) {
var a = 1;
let b = 2;
}
console.log(a);
console.log(b);
}
Before the block — at function entry:
The engine runs f's creation phase. It scans the entire function body, including inside the block, looking for var and function declarations. (It does not yet look for let/const inside the block — those wait for the block-entry creation phase.)
It finds: var a inside the block. Creates a Binding for a in f's record. Value: undefined.
That last sentence is critical: var a is installed in f's record during f's creation phase, even though it's written syntactically inside the block. The block isn't entered yet at this moment. The function's creation phase scans through the block's source code, finds the var, and hoists the Binding all the way up to the function's record.
After f's creation phase:
f's record:
bindings: { a: undefined }
[[OuterEnv]] → script's env
EC for f:
LE → f's record
VE → f's record
There's no b anywhere yet. let b is not installed during f's creation phase — let/const only hoist to the nearest enclosing scope, which is the block, and the block hasn't been entered.
Execution begins. The first line is if (true) {. The engine evaluates the condition (true), then begins entering the block.
The block contains a let b declaration. So the engine performs the four-step ritual:
Creates
blockRecord.Block's creation phase: finds
let b, creates Binding forbinblockRecordwith value EMPTY.Sets
blockRecord.[[OuterEnv]] = f's record.Swaps
EC.LexicalEnvironmenttoblockRecord.VariableEnvironmentstays pointing atf's record.
State after step 4:
blockRecord:
bindings: { b: EMPTY }
[[OuterEnv]] → f's record
f's record:
bindings: { a: undefined }
[[OuterEnv]] → script's env
EC for f:
LE → blockRecord (swapped)
VE → f's record (unchanged)
The two pointers have diverged. Both still exist on the same EC. They just point at different records.
Now var a = 1; runs. The engine wants to assign 1 to the a Binding. The question is: where does var declarations look?
var uses VariableEnvironment. The engine looks at EC.VariableEnvironment, which still points at f's record. The Binding for a is there (it was hoisted during f's creation phase). The engine writes 1 into it.
a is now 1. It lives in f's record.
Now let b = 2; runs. The engine wants to initialize the b Binding. let uses LexicalEnvironment. The engine looks at EC.LexicalEnvironment, which points at blockRecord. The Binding for b is there with value EMPTY. The engine flips it to 2.
b is now 2. It lives in blockRecord.
State:
blockRecord:
bindings: { b: 2 }
f's record:
bindings: { a: 1 }
The two declarations, on adjacent lines in the same block, landed in different records.
Now the closing }. The engine restores EC.LexicalEnvironment back to f's record. blockRecord is no longer pointed at by any EC. Nothing else points at it (no inner function was defined inside this block). It becomes garbage. b is gone.
State after block exit:
f's record:
bindings: { a: 1 }
EC for f:
LE → f's record
VE → f's record (back in sync)
Now console.log(a); runs. The engine looks up a. Starts at LexicalEnvironment = f's record. Finds a = 1. Prints 1.
Now console.log(b); runs. The engine looks up b. Starts at f's record. Not there. Walks [[OuterEnv]] to script's env. Not there. Hits null. Throws:
ReferenceError: b is not defined
This is not the TDZ error. It's the "not defined" error. There genuinely is no Binding for b in any reachable record. blockRecord is gone. The Binding for b died with it.
The behavior we observed — a survives, b doesn't — falls out mechanically from the pointer divergence. There's no rule called "function-scoped" or "block-scoped." There are two pointers that disagree, and var and let target different ones.
A small case study: block-scoped TDZ
Earlier I asked you to consider this version:
function f() {
console.log(a); // undefined
console.log(b); // ReferenceError: b is not defined
if (true) {
var a = 1;
let b = 2;
}
}
We can now trace it precisely. At the moment of the console.log calls:
f's creation phase has run.ais inf's record with valueundefined(hoisted from the var inside the block).The block hasn't been entered. There is no
blockRecordyet.bdoesn't exist anywhere.
So console.log(a) finds a = undefined and prints undefined. console.log(b) walks the chain, doesn't find b anywhere, throws "b is not defined."
If you wanted the TDZ error for b, you'd need to call console.log after entering the block but before the declaration line:
function f() {
if (true) {
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 2;
}
}
Now the block has been entered. The block's creation phase has run. blockRecord exists with b: EMPTY. The console.log finds b, sees EMPTY, throws TDZ.
Each block with lexical declarations has its own TDZ window — from the moment the block is entered until the moment each declaration line initializes its variable.
Section 2.5: Why var inside a block ends up in the function's record
This deserves its own subsection because it's the single most mechanically surprising fact in this post.
The function's creation phase scans the entire function body, including code inside nested blocks. Every var declaration anywhere in the function — no matter how deeply nested — gets a Binding pre-installed in the function's record at function entry.
This rule is what gives var its "function-scoped" behavior. The behavior is a consequence, not a primitive.
Example with deep nesting:
function f() {
console.log(deep); // undefined — not "not defined"
if (true) {
if (true) {
if (true) {
var deep = "I am here";
}
}
}
}
f();
When f's creation phase runs, it scans the body recursively. It descends into every block and every nested block. It finds var deep three levels in. It creates a Binding for deep in f's record with value undefined.
By the time the first line executes, deep exists in f's record. It's undefined. The console.log prints undefined.
Contrast with let:
function f() {
console.log(deep); // ReferenceError: deep is not defined
if (true) {
if (true) {
if (true) {
let deep = "I am here";
}
}
}
}
Same code, var → let. Now f's creation phase does not install deep. let declarations are only hoisted to their nearest enclosing block, and that block hasn't been entered yet.
So console.log(deep) walks the chain, doesn't find deep, throws "not defined."
The unifying principle
Each Environment Record has its own creation phase, run when the record is created. A function's record is created at function entry; its creation phase scans the function body for var and function declarations. A block's record is created at block entry; its creation phase scans the block body for let/const/class declarations.
var and function declarations are only hoisted by function-record creation phases — they ignore any blocks they're nested inside. let, const, and class declarations are only hoisted by block-record creation phases — they don't reach up to the function level.
That's why the EC has two pointers. var was designed to bypass intermediate scopes, so it gets its own pointer that doesn't move when intermediate scopes are entered.
Section 3: Why two pointers exist (the historical reason)
You might reasonably ask: why was JavaScript designed this way? Couldn't the spec have used a single pointer and figured out where to put var some other way? The two-pointer design isn't the only possible mechanism. It's a specific choice. Worth understanding why.
JavaScript shipped in 1995. It had var. Variables declared with var had function scope: a var declared anywhere inside a function existed for the entire function, regardless of which block it was nested in. Tons of code was written over the next twenty years assuming this behavior.
In 2015, ECMAScript 6 added let and const with block scope. Block scope is what most other languages have — it matches what programmers from C, Java, Python, and similar backgrounds expect. It also eliminates a class of bugs around variable shadowing and loop closures.
The problem: the spec team couldn't change var. Existing code on the web relied on its function-scoping behavior. Changing it would have broken the internet.
So the team had to design a system where both behaviors coexist in the same function, in the same line of code if you want to mix them. The two-pointer EC was the solution:
vardeclarations targetVariableEnvironment, which is stable through block entry.varkeeps its 1995 behavior.letandconsttargetLexicalEnvironment, which swaps on block entry. They get block scope.
Same EC. Same execution flow. Two pointers. No backward incompatibility.
You could imagine alternative designs:
"Use one pointer and walk up to find the function's record when installing a
var." This would work, but it's slower (everyvarinstallation requires a walk) and it makes the spec messier — everyvaroperation needs a "find the function-level record" step."Special-case
varso it ignores intermediate block records." This works conceptually but pushes the special-case logic into many spec operations. The two-pointer design localizes the special-casing into one place: the EC's two fields."Just change
varto be block-scoped." This would have broken the web. Non-starter.
The two-pointer design is, in essence, caching the answer. The function-level record is what var always needs to find. Instead of computing it on every var operation, the EC just keeps a direct pointer.
You don't need to memorize this section. The point is: the two-pointer design is intentional and rests on backward compatibility. If you ever find yourself wondering why JavaScript looks weird, the answer is almost always "because it had to remain compatible with code from before that weirdness was a problem."
Section 4: Other places blocks happen
The four-step block-entry ritual we covered in Section 2 applies to any block that introduces lexical declarations. The block doesn't have to be inside an if. There are several places in JavaScript where the same machinery kicks in.
For-let loops
The most consequential is for loops with let:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
This prints 0, 1, 2. The same code with var prints 3, 3, 3.
Why? Because for (let i = ...) doesn't just create one Environment Record — it creates one per iteration.
The spec defines a special evaluation for this kind of for-loop. Before the first iteration, the engine creates an Environment Record for the loop's binding (i). Before each iteration, the engine creates a new record and copies i's current value into it. Any inner function created during that iteration captures that iteration's record.
For three iterations, you get three separate Environment Records, each with its own i Binding. The three setTimeout callbacks have [[Environment]] pointers to three different records. When they eventually run, each reads its own i.
With var, there's no per-iteration record. There's one var i Binding in the enclosing function's record. All three callbacks capture pointers up to that one Binding. By the time they run, the loop has finished and i is 3. All three print 3.
This is the famous "loop closure" gotcha. It's not a special mechanism — it's the per-iteration block ritual we already understand, applied to the for-loop's binding.
Catch clauses
When you write:
try {
doSomething();
} catch (err) {
// handle err
}
The catch (err) creates a new Environment Record holding the err Binding. The catch body sees that record. After the catch exits, the record is gone (unless something captured it).
This is the four-step ritual applied to catch syntax. The catch parameter is treated like a let binding in a block.
Function declarations inside blocks: strict vs sloppy
Here's where the language gets historically messy and where you can pick up an interview-grade detail most candidates don't know.
What does this code do?
function f() {
if (true) {
function g() { return 42; }
}
return g();
}
f();
The answer depends on whether the code is in strict mode or sloppy mode.
In strict mode (which is the default in classes, modules, and any file with "use strict"):
ReferenceError: g is not defined
In sloppy mode (which is the default in old-style scripts without "use strict"):
42
Why the difference?
In strict mode, a function declaration inside a block behaves like let. The function g() {} creates a Binding for g in the block's record, not the function's record. When the block exits, g's Binding goes with it. Outside the block, g is not defined.
In sloppy mode, the spec specifies an "Annex B" legacy behavior — a set of compatibility rules that exists because pre-ES6 browsers did something different from each other, and ES6 had to standardize something. Under Annex B.3.3, the engine creates a Binding for g in both the block's record (block-scoped) and the function's VariableEnvironment (var-scoped). The function-scoped Binding gets updated each time the block runs. So after the block, g exists at function scope, with the value it had when the block exited.
The reader doesn't need to memorize Annex B's details. What you should remember:
Function declarations inside blocks are messy. Different rules in different modes.
In strict mode, they behave like
let. Block-scoped.In sloppy mode, there's a "web legacy" rule that adds extra scoping to mimic pre-ES6 browser behavior. It's a backward-compatibility hack.
Modern code is almost always strict. Class bodies are automatically strict. ES modules are automatically strict. Any
"use strict"directive makes the file strict. So in practice, you're almost always in the strict-mode world.
If you find yourself relying on the sloppy-mode behavior, you're depending on legacy compatibility logic that is intentionally underspecified. Restructure the code to make the function declaration explicit (move it out of the block, or use a let f = function() {} expression).
Section 5: Reading variables across the split
LexicalEnvironment and VariableEnvironment diverge during block execution. They start equal at function entry, separate at block entry, rejoin at block exit. During the diverged window, the two pointers point at different records.
Reads use LexicalEnvironment. Writes to declared variables use whichever pointer the declaration kind targeted (let/const → LexicalEnvironment, var → VariableEnvironment).
When you write a name inside a block:
let x = ...— write goes throughLexicalEnvironment, hits the block record.var x = ...— write goes throughVariableEnvironment, hits the function record.x = ...(assignment to existing variable) — engine walks the chain starting fromLexicalEnvironment, finds the nearest Binding forx, writes there.console.log(x)— engine walks the chain starting fromLexicalEnvironment, finds the nearest Binding forx, reads it.
Reads always use LexicalEnvironment. The reader doesn't need to think about VariableEnvironment when reasoning about reads.
A small worked example with shadowing:
function f() {
var x = "function";
if (true) {
let x = "block";
console.log(x); // "block"
}
console.log(x); // "function"
}
Two Bindings for x exist while the block is active: one in f's record (the var x), one in blockRecord (the let x). Inside the block, lookup starts at blockRecord, finds x = "block", returns it. Outside the block (after block exit), blockRecord is gone, and lookup finds x = "function" in f's record.
This is shadowing. The inner x hides the outer x for the duration of the block. The outer one is never modified; it's just not visible from within the block because the lookup stops at the inner match.
Section 6: V8's view of blocks
We've been describing the spec mechanism. As always, V8 implements an optimized version.
V8's parse-time scope analysis applies to blocks the same way it applies to functions. For each block with lexical declarations, V8 asks: does any function defined inside this block reference these block variables?
No → the block's variables live on the function's stack frame. No heap Context is allocated for the block.
Yes → V8 allocates a heap Context for the block. The captured variables live in it.
You can observe this in DevTools. Try the following:
function f() {
let outerVar = 1;
if (true) {
let blockVar = 2;
const inner = () => {
debugger;
return outerVar + blockVar;
};
inner();
}
}
f();
When the debugger pauses, look at the Scope panel:
Scope
├── Local (inner's local scope — empty)
├── Block ← V8 allocated a heap Context for the if-block
│ blockVar: 2
├── Closure (f) ← V8 allocated a heap Context for f's scope
│ outerVar: 1
├── Script
└── Global
Two separate Contexts: one labeled "Block" (the if-block's record), one labeled "Closure (f)" (f's function record). Each holds the variables that V8 determined are captured by inner.
If you delete outerVar from the body of inner, the "Closure (f)" entry disappears from the Scope panel — V8's analysis decides f's variables don't need to survive, and stack-allocates them. The Block entry stays, because blockVar is still captured.
This is V8 showing you, in real time, the spec's two-record structure mapped onto its heap allocation strategy. The two entries you see in DevTools correspond exactly to the two Environment Records the spec describes.
Section 7a: A closure through a single block
Closures from Part 2 worked at the function level: an inner function captured its enclosing function's Environment Record. Now that blocks create their own records, closures can capture block records too. The mechanism is identical.
function makeGreeter(name) {
if (true) {
const greeting = `Hello, ${name}!`;
return () => greeting;
}
}
const greet = makeGreeter("World");
greet(); // "Hello, World!"
Let's trace.
Calling makeGreeter("World"):
Create
makeGreeterRecord. Parameternamebound to"World".EC pushed: LE and VE both point at
makeGreeterRecord.Execute the body.
Body line 1: if (true) {. The block contains a const greeting declaration. The four-step ritual fires:
Create
blockRecord.Block creation phase: Binding for
greetingwith value EMPTY.blockRecord.[[OuterEnv]] = makeGreeterRecord.EC.LexicalEnvironment→blockRecord.VariableEnvironmentstays atmakeGreeterRecord.
Body line 2: const greeting = .... The engine evaluates the template literal ("Hello, World!") and initializes the Binding in blockRecord.
Body line 3: return () => greeting;. The engine creates an arrow function (a Function Object). At creation time, it sets arrow.[[Environment]] = currentEC.LexicalEnvironment = blockRecord.
The function returns.
Now: what happens to the records when makeGreeter returns?
makeGreeter's EC is popped from the stack.makeGreeterRecordwould be unreachable except —blockRecord.[[OuterEnv]]still points at it.blockRecordwould be unreachable except — the returned arrow function's[[Environment]]still points at it.
So we have a chain:
greet (the returned arrow)
→ arrow.[[Environment]]
→ blockRecord (with greeting: "Hello, World!")
→ blockRecord.[[OuterEnv]]
→ makeGreeterRecord (with name: "World")
→ makeGreeterRecord.[[OuterEnv]]
→ script's env
Three records survive makeGreeter's return: script's env (always alive), makeGreeterRecord, and blockRecord. All kept alive transitively through the arrow function's [[Environment]] pointer.
When you call greet(), a new Environment Record is created with [[OuterEnv]] = arrow.[[Environment]] = blockRecord. The body looks up greeting, finds it in blockRecord, returns it.
The mechanism is identical to Part 2's makeCounter. The only new piece: there's an extra record (the block's) in the chain.
Section 7b: A four-level nested closure stress test
If you've made it this far, you have the entire mental model. Time to stress-test it.
Consider this code. Don't try to predict the trace yet — just read it:
function outer() {
let outerVar = "outer";
{
let blockVar1 = "block1";
return function middle() {
let middleVar = "middle";
{
let blockVar2 = "block2";
return function inner() {
return [outerVar, blockVar1, middleVar, blockVar2];
};
}
};
}
}
const fn = outer()();
fn(); // ["outer", "block1", "middle", "block2"]
Four levels: outer (function) → block in outer → middle (function) → block in middle → inner (function). The innermost function reads variables from all four levels.
By the time fn() finally runs, both outer and middle have long since returned. Their ECs are gone. Yet fn can still read outerVar, which was a local of outer.
How many Environment Records survive? Let's trace.
Calling outer():
Creates outerRecord. Body runs.
let outerVar = "outer"; — initializes.
{ — block contains let blockVar1. Four-step ritual creates outerBlockRecord. LE swaps to outerBlockRecord. VE stays at outerRecord.
let blockVar1 = "block1"; — initializes in outerBlockRecord.
return function middle() { ... }; — creates middle Function Object. middle.[[Environment]] = outerBlockRecord (the current LE).
outer returns. Its EC pops.
Now, who's alive?
middle.[[Environment]] → outerBlockRecord— keepsouterBlockRecordalive.outerBlockRecord.[[OuterEnv]] → outerRecord— keepsouterRecordalive.
Chain so far: middle → outerBlockRecord → outerRecord → script.
outer() returned middle. The expression outer()() immediately calls it.
Calling middle():
Creates middleRecord. Its [[OuterEnv]] = middle.[[Environment]] = outerBlockRecord. Body runs.
let middleVar = "middle"; — initializes in middleRecord.
{ — inner block contains let blockVar2. Ritual creates middleBlockRecord. LE swaps. VE stays.
let blockVar2 = "block2"; — initializes.
return function inner() { ... }; — creates inner Function Object. inner.[[Environment]] = middleBlockRecord.
middle returns.
Who's alive?
inner.[[Environment]] → middleBlockRecordmiddleBlockRecord.[[OuterEnv]] → middleRecordmiddleRecord.[[OuterEnv]] → outerBlockRecordouterBlockRecord.[[OuterEnv]] → outerRecord
The chain is now four records deep, all alive transitively:
fn (the inner function, assigned from outer()())
→ inner.[[Environment]] → middleBlockRecord (blockVar2)
↓ [[OuterEnv]]
middleRecord (middleVar)
↓ [[OuterEnv]]
outerBlockRecord (blockVar1)
↓ [[OuterEnv]]
outerRecord (outerVar)
↓ [[OuterEnv]]
script's env
Four Environment Records, all surviving past the returns of both outer and middle, all kept alive purely by the chain of [[OuterEnv]] and [[Environment]] pointers terminating at fn.
Calling fn():
Creates innerRecord. [[OuterEnv]] = inner.[[Environment]] = middleBlockRecord.
Body: return [outerVar, blockVar1, middleVar, blockVar2];
Each variable lookup walks the chain from innerRecord:
outerVar→ not in innerRecord, walk to middleBlockRecord, not there, walk to middleRecord, not there, walk to outerBlockRecord, not there, walk to outerRecord — found,"outer".blockVar1→ walk to middleBlockRecord, middleRecord, outerBlockRecord — found,"block1".middleVar→ walk to middleBlockRecord, middleRecord — found,"middle".blockVar2→ middleBlockRecord — found,"block2".
Returns ["outer", "block1", "middle", "block2"].
Every lookup that needs a value walks back through however many records are between it and the target. The mechanism doesn't change with depth. The same two pointer-assignment rules from Part 2 ([[Environment]] at function creation, [[OuterEnv]] at function call) plus the block-ritual rules from this post handle arbitrary nesting.
If fn is set to null after this call, the entire chain dissolves:
fnno longer referencesinner.innerbecomes unreachable.middleBlockRecordbecomes unreachable.middleRecordbecomes unreachable.outerBlockRecordbecomes unreachable.outerRecordbecomes unreachable.All five would be reclaimed in the next GC pass.
Reachability is transitive. One reference at the top keeps everything alive. Remove that reference and everything dies together.
Section 8: Runnable simulation, extended
Extending the simulation from Part 2 to support blocks. The new pieces: a helper to enter a block (perform the four-step ritual) and a helper to exit it.
// Reuse: EMPTY, Binding, EnvironmentRecord, FunctionObject, callFunction from Part 2.
// The EC structure
class ExecutionContext {
constructor(lexicalEnv, variableEnv) {
this.lexicalEnvironment = lexicalEnv;
this.variableEnvironment = variableEnv;
}
}
// Stack of ECs
const callStack = [];
// Enter a block: create new record, swap LE only.
function enterBlock(lexicalDeclarations) {
const ec = callStack[callStack.length - 1];
const previousLE = ec.lexicalEnvironment;
const blockRecord = new EnvironmentRecord(previousLE);
// Block's creation phase
for (const decl of lexicalDeclarations) {
blockRecord.createBinding(decl.name, decl.kind === "let");
}
ec.lexicalEnvironment = blockRecord;
return { previousLE, blockRecord };
}
// Exit a block: restore LE.
function exitBlock(snapshot) {
const ec = callStack[callStack.length - 1];
ec.lexicalEnvironment = snapshot.previousLE;
}
Now simulate the opening puzzle:
const scriptEnv = new EnvironmentRecord(null);
scriptEnv.createBinding("f", true);
const f = new FunctionObject({
name: "f",
parameters: [],
environment: scriptEnv,
body: function (env) {
// f's creation phase
env.createBinding("a", true);
env.initializeBinding("a", undefined); // var hoisting
// Push EC for f
const ec = new ExecutionContext(env, env);
callStack.push(ec);
try {
// Now enter the if-block
const snapshot = enterBlock([{ name: "b", kind: "let" }]);
// var a = 1 → uses VE, hits env (f's record)
ec.variableEnvironment.setMutableBinding("a", 1);
// let b = 2 → uses LE, hits blockRecord
ec.lexicalEnvironment.initializeBinding("b", 2);
exitBlock(snapshot);
// console.log(a) — read via LE
const aValue = ec.lexicalEnvironment.getBindingValue("a");
console.log(aValue); // 1
try {
const bValue = ec.lexicalEnvironment.getBindingValue("b");
console.log(bValue);
} catch (e) {
console.log(e.message); // b is not defined
}
} finally {
callStack.pop();
}
},
});
scriptEnv.initializeBinding("f", f);
callFunction(f, []);
Output:
1
b is not defined
You can see the pointer divergence in code: the var a = 1 uses ec.variableEnvironment, the let b = 2 uses ec.lexicalEnvironment. After block exit, the b lookup walks the chain and fails.
This is the spec model, in 100 lines of JavaScript.
Section 9: What we built, and what's next
Three posts in, and you can now mechanically explain every behavior involving variables in JavaScript.
What we built in Part 3:
The EC has two Environment Record pointers:
LexicalEnvironmentandVariableEnvironment. They start equal at function entry.Entering a block with
let/const/classdeclarations triggers a four-step ritual: create new record, run block's creation phase, set[[OuterEnv]], swapLexicalEnvironmentonly.vardeclarations targetVariableEnvironment(which doesn't move).let/consttargetLexicalEnvironment(which does). This is the entire mechanism behind their different scoping rules.The function-level creation phase scans the entire function body and hoists every
varto the function's record, regardless of block nesting.for-letloops create a new Environment Record per iteration, which is why each iteration'siis independent in closure captures.Strict-mode and sloppy-mode treat function declarations inside blocks differently. Modern code (classes, modules) is automatically strict.
The block-ritual mechanism scales to arbitrary depth. Closures through nested blocks and functions keep arbitrarily long chains of records alive in the heap.
We've now covered every variable in JavaScript. How it's declared. Where its Binding lives. How it's looked up. When its scope is created. When its Environment Record can survive past its function's return.
Every variable, that is, except one.
There is one identifier in JavaScript that doesn't follow any of these rules. It isn't installed in a Binding during the creation phase. It isn't looked up through the [[OuterEnv]] chain. Two calls to the same function can give it different values, even though every other variable in those calls would behave identically. It can even be deliberately missing from a function entirely — as is the case for arrow functions.
That identifier is this. Part 4 explains why it's the exception, how it actually works, and what arrow functions do differently — not as a special-case in the language, but as a single bit of state on the Function Object.
This is Part 3 of a four-part series on the JavaScript environment model. Next: Part 4 — "Why this Is the Only Exception."
