Scope Chains and Lexical Scope
Where Does a Variable Live?
When JavaScript encounters a variable name, it needs to find where that variable was defined. This lookup process isn't random — it follows a rigid chain of lexical environments, determined entirely by where you wrote the code, not where it runs. This is called lexical scoping, and mastering it unlocks your understanding of closures, hoisting, and module patterns.
Imagine a series of nested transparent boxes. Each box is a scope — it holds variables defined inside it. When you look for a variable, you check the box you're in. If it's not there, you look at the box outside. Then the box outside that. You keep going outward until you either find the variable or reach the outermost box (global scope). You never look inward or sideways — only outward.
Types of Scope
JavaScript has three types of scope:
Global Scope
Variables declared at the top level. In browsers, they live on window. In Node, they live in the module scope (not truly global).
var x = 1; // global scope (window.x in browsers)
let y = 2; // global scope but NOT on window
const z = 3; // global scope but NOT on window
var in global scope creates a property on window. let and const don't. They live in a separate "declarative environment record" that's not accessible as object properties. This is why window.x works but window.y doesn't.
Function Scope
var declarations are scoped to their containing function, not their containing block:
function example() {
if (true) {
var x = 10; // scoped to example(), not the if-block
}
console.log(x); // 10 — var ignores block boundaries
}
Block Scope
let and const are scoped to the nearest {} block:
function example() {
if (true) {
let x = 10; // scoped to the if-block
const y = 20; // scoped to the if-block
}
console.log(x); // ReferenceError: x is not defined
}
The Scope Chain — How Variable Resolution Works
Every execution context has a reference to its outer lexical environment. This creates a chain:
const global = "I'm global";
function outer() {
const outerVar = "I'm outer";
function middle() {
const middleVar = "I'm middle";
function inner() {
const innerVar = "I'm inner";
// Scope chain for inner():
// inner scope → middle scope → outer scope → global scope
console.log(innerVar); // found in inner scope
console.log(middleVar); // found in middle scope
console.log(outerVar); // found in outer scope
console.log(global); // found in global scope
}
inner();
}
middle();
}
outer();
Lexical vs Dynamic Scoping
JavaScript uses lexical (static) scoping. The scope chain is determined by where the function is defined in the source code, not where it's called:
const x = "global";
function printX() {
console.log(x); // Which x?
}
function wrapper() {
const x = "local";
printX(); // "global" — not "local"!
}
wrapper();
printX was defined in the global scope, so its outer scope is global. It doesn't matter that wrapper called it from a scope where x is "local".
In a dynamically scoped language (like Bash or some Lisps), printX() would log "local" because it would look up x in the calling scope. JavaScript chose lexical scoping because it's predictable — you can determine variable bindings just by reading the code, without tracing execution paths.
Variable Shadowing
When an inner scope declares a variable with the same name as an outer scope, the inner one shadows the outer:
const x = "outer";
function demo() {
const x = "inner"; // shadows the outer x
console.log(x); // "inner"
}
demo();
console.log(x); // "outer" — unaffected
Shadowing is not reassignment. The outer variable still exists and is untouched. The inner scope simply has its own binding with the same name, so the lookup finds it first and stops.
The specification model — Environment Records
In the ECMAScript spec, scope is modeled through Lexical Environments, each containing an Environment Record (the actual variable storage) and an outer reference (the link to the parent scope).
There are three kinds of Environment Records:
- Declarative: For
let,const,class,function,varinside functions,catchparameters - Object: For
withstatements and the global object bindings ofvar - Global: A hybrid — a Declarative record for
let/constand an Object record backed by the global object forvar
When you write let x = 5, a new binding is created in the current Declarative Environment Record. When you reference x, the engine calls GetBindingValue on the current environment. If not found, it follows the outer reference and tries again. This repeats until the global environment. If still not found, it throws ReferenceError.
Production Scenario: Accidental Global Variables
function processData(items) {
for (i = 0; i < items.length; i++) { // Bug: missing var/let/const
transform(items[i]);
}
}
// i is now a global variable (window.i in browsers)
// If another function also uses an undeclared i, they collide
// In strict mode, this throws ReferenceError instead
This is why "use strict" (or ESM modules, which are strict by default) matters. Without it, assigning to an undeclared variable silently creates a global.
"use strict";
function processData(items) {
for (i = 0; i < items.length; i++) {
// ReferenceError: i is not defined
// Bug caught immediately
}
}
| What developers do | What they should do |
|---|---|
| Assuming scope is determined by where a function is called JavaScript uses static/lexical scoping — the scope chain is fixed at definition time | Scope is determined by where a function is defined (lexical scoping) |
| Forgetting var/let/const and accidentally creating globals Undeclared assignments create global variables in sloppy mode | Always use let or const. Enable strict mode or use ESM. |
| Thinking var respects block scope var declarations are hoisted to the function boundary, ignoring if/for/while blocks | var is function-scoped. Only let and const are block-scoped. |
| Using var in for loops and expecting each iteration to have its own binding var creates one binding shared across all iterations. let creates a new binding per iteration. | Use let in for loops — each iteration gets a fresh binding |
- 1JavaScript uses lexical scoping — the scope chain is determined by where code is written, not where it executes
- 2Variable lookup walks outward through the scope chain, never inward or sideways
- 3var is function-scoped (ignores blocks). let and const are block-scoped.
- 4In global scope, var creates a property on window. let and const do not.
- 5Always declare variables. Undeclared assignments create accidental globals in sloppy mode.