๐Ÿณ IntuitiveFE
Login
โ† All concepts

Scope & Lexical Environments

โฑ๏ธ ~4-minute bite ยท solve the sandbox to master

0%lesson
๐Ÿง’

5-Year-Old Metaphor

โ€” The physical, real-world picture. No jargon.

๐Ÿช† Russian dolls (matryoshka) โ€” each function or block creates a new Environment Record nested inside the outer one.

The matryoshka analogy

Imagine a set of Russian nesting dolls. The smallest (innermost) doll represents the currently-running function. Surrounding it are larger dolls โ€” the enclosing functions, blocks, module, and finally the global scope.

When JavaScript looks up a variable, it opens the innermost doll first. If the variable isn't there, it opens the next doll outward โ€” and keeps going until it either finds the binding or opens the outermost doll and finds nothing. In the latter case it throws a ReferenceError.

Crucially: the nesting is determined at write time โ€” by where you write your code in the source file โ€” not at call time. This is called lexical scoping.

LexicalEnvironment vs VariableEnvironment โ€” two slots, one context

Every Execution Context (function call, module, global code) carries two environment slots. Most of the time they point to the same object โ€” but they can diverge:

LexicalEnvironment

Handles let, const, and block-scoped bindings. A new LexicalEnvironment is created for every {} block entered.

VariableEnvironment

Handles var hoisting in function bodies. Stays fixed for the lifetime of the function call โ€” blocks don't create new ones. This is why var "escapes" if/for blocks.

The two slots diverge only inside try/catch (the catch param gets its own LexicalEnvironment) and the legacy with statement.

Environment Record hierarchy (the spec)

The ECMAScript spec defines a hierarchy of Environment Record types. Each kind handles a different scoping scenario:

DeclarativeEnvironmentRecord

Used for let, const, function, class, and import bindings inside blocks and functions. Bindings are stored directly as nameโ†’value pairs.

ObjectEnvironmentRecord

Used for global var bindings (backed by the global object / window) and the with statement. Property lookup on an actual object โ€” much slower.

GlobalEnvironmentRecord

Wraps both: a DeclarativeEnvironmentRecord for let/const/class, and an ObjectEnvironmentRecord for var and function declarations at the top level (window in browsers).

ModuleEnvironmentRecord

A specialized DeclarativeEnvironmentRecord for ES modules. Imported bindings are live โ€” they stay in sync with the exporting module's bindings.

Static (lexical) vs dynamic scope

JavaScript uses lexical scope: the scope chain is determined at write time โ€” by the physical nesting of source code. A function always looks up variables in the environment where it was defined, not where it was called.

Lexical scope (JS โ€” write-time)

js
1const x = 'outer';
2function inner() { return x; }
3function caller() {
4 const x = 'local'; // irrelevant!
5 return inner(); // โ†’ 'outer'
6}

Dynamic scope (NOT JS โ€” hypothetical)

js
1const x = 'outer';
2function inner() { return x; }
3function caller() {
4 const x = 'local';
5 return inner(); // would โ†’ 'local'
6 // (call-site x wins โ€” this is NOT JS)
7}

eval() and with are the only constructs that can dynamically modify scope at runtime โ€” which is exactly why they break static analysis and are forbidden in strict mode / modern code.

๐ŸŽ›๏ธ

Interactive Sandbox

โ€” Move something, see it react instantly.

Pattern

Code

js
1var globalX = 'global';
2function outer() {
3 var outerX = 'outer';
4 function inner() {
5 var innerX = 'inner';
6 console.log(outerX); // โ† lookup
7 }
8 inner();
9}

Scope chain โ€” lookup of outerX from inner() scope

inner()function
varinnerX='inner'
outer env โ†’
outer()function
varouterX='outer'โœ“ FOUND
functioninner=ฦ’ inner()
outer env โ†’
Globalglobal
varglobalX='global'
functionouter=ฦ’ outer()

Lookup trace

Looking up outerX from inner() scope: checked 2 environments, found at outer()

Gotcha: inner() can read outerX because it walks up the scope chain to outer().
Key insight: Each function call creates its own Environment Record. The chain is determined at write time โ€” where the functions are defined, not where they are called.
Explored:๐Ÿ”ญ๐Ÿงฑ๐ŸŒ‘โ›“๏ธ๐Ÿ“ฆ
๐ŸŽฏ

Challenge

Explore all 5 scope patterns. For each, trace the lookup path from the innermost environment outward โ€” notice exactly where the engine stops.

Try it
๐ŸŽฏ

Why Should I Care?

โ€” The exact interview question + the bug it kills.

Exact interview questions

Q: Difference between scope and execution context?

Scope is a static concept โ€” the region of code where a variable is visible. It's determined by where you write your declarations. An Execution Context is a runtime object created each time a function is called; it contains the LexicalEnvironment, VariableEnvironment, and this binding. Multiple calls to the same function produce multiple Execution Contexts, but they all share the same lexical scope structure.

Q: Why does var leak out of if blocks but let doesn't?

js
1if (true) {
2 var leaked = 'yes'; // goes to VariableEnvironment (function/global)
3 let blocked = 'no'; // goes to LexicalEnvironment (this block only)
4}
5console.log(leaked); // 'yes' โ€” var ignores blocks
6console.log(blocked); // ReferenceError โ€” let is block-scoped

var declarations are processed by the VariableEnvironment, which is shared across the entire function (or global scope). Entering a {} block does not create a new VariableEnvironment. let and const live in the LexicalEnvironment โ€” and each block gets its own fresh one.

Q: What is lexical scope and how does it differ from dynamic scope?

Lexical (static) scope means the scope chain is fixed at write time by the physical nesting of source code. A function looks up variables in its definition environment, not its call environment. Dynamic scope (used by some other languages) would walk the call stack instead. JavaScript is always lexically scoped โ€” with the exception of eval() in non-strict mode, which can inject new bindings into the surrounding scope at runtime.

Production bugs scope causes

Bug: var in for loop captured by wrong scope

js
1// โœ— Classic closure + var bug
2const handlers = [];
3for (var i = 0; i < 3; i++) {
4 handlers.push(() => console.log(i));
5}
6handlers[0](); // 3 โ€” not 0!
7handlers[1](); // 3 โ€” not 1!
8handlers[2](); // 3 โ€” not 2!
9ย 
10// โœ“ Fix 1: use let โ€” fresh binding per iteration
11for (let i = 0; i < 3; i++) {
12 handlers.push(() => console.log(i));
13}
14ย 
15// โœ“ Fix 2: IIFE (pre-ES6 pattern)
16for (var i = 0; i < 3; i++) {
17 (function(j) { handlers.push(() => console.log(j)); })(i);
18}

var i is function-scoped โ€” all closures share one binding. By the time any handler fires, the loop has finished and i === 3. The IIFE pattern creates a new function scope per iteration to capture the current value. let does this automatically.

Bug: accidental global variable creation

js
1// In non-strict mode โ€” assignment to undeclared var
2// creates a property on the global object silently!
3function processUser(data) {
4 result = transform(data); // โ† missing let/const/var!
5 return result;
6}
7processUser({ name: 'Alice' });
8console.log(window.result); // 'alice-transformed'
9// This leaks across every function in the page.
10ย 
11// Fix: always 'use strict' (or use a bundler/TS that implies it)
12// or always declare with let/const/var

Strict mode makes this a ReferenceError immediately. Accidental globals in non-strict mode cause state corruption across modules and are nearly impossible to track down in large apps.

The IIFE pattern โ€” why it existed

js
1// Before ES modules and let/const (pre-2015):
2// Developers wrapped entire libraries in IIFEs to create private scope
3(function() {
4 var privateState = []; // invisible to outside world
5 function privateHelper() { /* ... */ }
6ย 
7 // Only expose what's needed on window
8 window.MyLib = {
9 doThing: function() { return privateHelper(); },
10 };
11})();
12// privateState and privateHelper are gone โ€” not on window

The IIFE (Immediately Invoked Function Expression) was the primary way to create module-like private scope before ES modules. Every function call creates a new scope โ€” by immediately invoking an anonymous function, you get a private bubble with no variable leakage into the global scope. jQuery, Underscore, and virtually every major pre-module library used this pattern.

Today: ES modules (import/export) give you module scope automatically, and let/const give you block scope. IIFEs are largely obsolete in modern code but appear heavily in legacy codebases.

๐Ÿ”ฌ

The Deep Dive

โ€” Spec refs, engine internals, the minutiae.

ES spec: the four NewEnvironment algorithms

The ECMAScript spec defines four algorithms for creating Environment Records. Each is called at a distinct moment in the execution lifecycle:

NewDeclarativeEnvironment(outer)

Called when entering a block, function body, or catch clause

Creates a DeclarativeEnvironmentRecord with its [[OuterEnv]] set to outer. Used for let/const/class/function (in blocks). The workhorse of modern JS scoping.

NewFunctionEnvironment(F, newTarget)

Called when a function F is invoked

Creates a FunctionEnvironmentRecord that extends DeclarativeEnvironmentRecord. Adds [[ThisValue]], [[NewTarget]], and [[HomeObject]] slots. Sets [[OuterEnv]] to F.[[Environment]] โ€” the closure chain.

NewModuleEnvironment(outer)

Called once when a module is linked

Creates a ModuleEnvironmentRecord. Imported bindings are created as 'import bindings' โ€” indirect references that stay live with the exporting module's binding.

NewGlobalEnvironment(G, thisValue)

Called once on realm initialization (page load, Worker start)

Creates a GlobalEnvironmentRecord combining an ObjectEnvironmentRecord (backed by global object G) and a DeclarativeEnvironmentRecord. var/function go to the ObjectER; let/const/class go to the DeclarativeER.

The with statement and ObjectEnvironmentRecord

js
1// with pushes an ObjectEnvironmentRecord onto the scope chain
2const obj = { x: 1, y: 2 };
3with (obj) {
4 console.log(x + y); // 3 โ€” found on obj via ObjectEnvironmentRecord
5}
6ย 
7// Why this is fatal for static analysis:
8with (mystery) {
9 // Is 'x' a local variable? A property of mystery?
10 // Impossible to know at compile time โ€” the engine must
11 // defer every lookup to runtime. V8 cannot optimize this.
12 doSomething(x);
13}

with inserts the object's properties as if they were local variables. The engine can no longer determine at compile time which environment a name belongs to โ€” it must check the ObjectEnvironmentRecord at runtime for every access. V8 cannot apply hidden class optimizations, inline caching, or dead code elimination inside a with block.

Forbidden in strict mode. Banned in CSP-enforced environments. Never use.

Strict mode impact on scope

Undeclared variable assignment โ†’ ReferenceError

js
1'use strict';
2accidentalGlobal = 42; // ReferenceError: accidentalGlobal is not defined
3// (In sloppy mode: silently creates window.accidentalGlobal)

with is a SyntaxError

js
1'use strict';
2with (obj) {} // SyntaxError โ€” not just discouraged, forbidden

eval gets its own scope

js
1'use strict';
2eval('var x = 1');
3console.log(x); // ReferenceError โ€” x stays inside eval's own scope
4// (In sloppy mode: x leaks into the surrounding scope)

ES modules and class bodies are always in strict mode โ€” you get all of the above protections automatically. 'use strict' at the top of a script or function enables them explicitly.

Block scope internals: each { } has its own LexicalEnvironment

js
1function demo() {
2 // LexEnv A (function body)
3 let a = 1;
4ย 
5 if (true) {
6 // LexEnv B โ†’ outer: A
7 let b = 2; // lives in B
8ย 
9 {
10 // LexEnv C โ†’ outer: B
11 let c = 3; // lives in C
12 console.log(a); // walks: C โ†’ B โ†’ A, finds a
13 console.log(b); // walks: C โ†’ B, finds b
14 }
15 // C destroyed, c gone
16 console.log(b); // walks: B, finds b
17 }
18 // B destroyed, b gone
19 console.log(a); // walks: A, finds a
20}

The engine creates a new LexicalEnvironment on every block entry (if, for, while, plain {}). Each let/const goes into the block's own DeclarativeEnvironmentRecord. When the block exits, that environment record becomes unreachable (unless a closure captured it) and is eligible for GC.

Performance: scope chain length and V8 optimizations

Every variable lookup walks the scope chain. A deeper chain = more work per lookup. V8 mitigates this via:

  • Slot-based resolution: for known-lexical variables (let/const in simple closures), V8 can resolve them to a fixed slot index at compile time โ€” O(1), no chain walk needed.
  • Context object reuse: if multiple closures in the same scope capture the same variable, they share one Context object (not one each).
  • Stack allocation when safe: variables not captured by any closure stay on the stack (no heap allocation, no GC pressure).
  • Deeply nested closures are slower: 10+ levels of scope chain forcing lookups that can't be slot-resolved will degrade performance. Flatten when possible in hot paths.

Chrome DevTools: inspecting lexical environments

  1. Open DevTools โ†’ Sources tab
  2. Set a breakpoint inside the function you want to inspect
  3. Trigger the breakpoint โ€” execution pauses
  4. Look at the Scope pane (right side). It shows every environment in the chain:
    • Local โ€” the innermost function's bindings
    • Block โ€” any enclosing block scopes
    • Closure (functionName) โ€” captured variables from outer functions
    • Module โ€” module-level bindings
    • Global โ€” window / globalThis
  5. You can hover over variables in the editor to see their current value from the Scope pane โ€” and even edit values in the Scope pane to test behavior without reloading.

Pro tip: the Scope pane shows exactly which scope level a variable was resolved from โ€” invaluable for debugging shadowing and unexpected closure captures.

๐ŸŽค

Interview Questions

โ€” Real questions from real interviews โ€” with answers.

`var` is function-scoped and hoisted to undefined; `let`/`const` are block-scoped and in the TDZ until their declaration.

`var` creates one shared binding for all iterations; `let` creates a fresh binding per iteration.

Lexical scope means a function's scope is determined by where it is written, not where it is called.

An IIFE creates a private function scope immediately โ€” the pre-module way to encapsulate state and avoid polluting the global scope.

Module-level `var` does NOT become a property of `globalThis` โ€” it stays in the module's own scope.

๐ŸŽฎ

Memory Game

โ€” Quick quiz โ€” lock the concept in long-term memory.
1/4

What happens when a function returns another function that references a variable from the outer function?