Lexical Scope
What is Lexical Scope?
Lexical scope (also called static scope) means that the scope of a variable is determined by where it is written in the source code — not where it is called from.
JavaScript uses lexical scope. When the JS engine looks up a variable, it looks at the code's written structure (where functions are defined), not the call stack (where functions are called from).
javascriptconst x = 'global';
function outer() {
const x = 'outer';
function inner() {
console.log(x); // 'outer' — uses where inner was DEFINED
}
inner();
}
outer(); // logs: 'outer'Even if inner was called from somewhere else, it would still see the x from where it was defined, not where it was called.
Lexical Scope vs Dynamic Scope
| Lexical Scope (JavaScript) | Dynamic Scope | |
|---|---|---|
| Determined by | Where function is defined | Where function is called |
| Lookup | Source code structure | Call stack |
| Examples | JavaScript, Python, C | Bash, old Perl, Emacs Lisp |
javascript// In JavaScript (lexical scope):
const name = 'global';
function greet() {
console.log(name); // always uses 'global' (where greet is defined)
}
function wrapper() {
const name = 'local';
greet(); // still logs 'global'
}
wrapper(); // logs: 'global'
// If JS had dynamic scope, it would log 'local'
// because 'name' = 'local' in the call stack at that pointThe Scope Chain
The scope chain is the ordered sequence of environments the JS engine traverses when resolving a variable name. It is built at parse time based on the static nesting of functions in the source code — not at runtime based on the call stack. Each environment record in the chain has a reference to its parent, forming a linked list from the innermost scope to the global scope. Variable lookup walks this chain from inner to outer and stops at the first match; if no match is found at any level, the result is a ReferenceError.
When JS looks up a variable, it walks up the scope chain — the nested structure of environments from inner to outer:
javascriptconst a = 1; // global scope
function level1() {
const b = 2;
function level2() {
const c = 3;
function level3() {
// Scope chain lookup:
console.log(c); // found in level3's own scope? No.
// found in level2's scope? Yes → 3
console.log(b); // level3? No. level2? No. level1? Yes → 2
console.log(a); // level3? No. level2? No. level1? No. global? Yes → 1
}
level3();
}
level2();
}
level1();Scope chain for level3:
level3 scope → level2 scope → level1 scope → global scope → (not found → ReferenceError)Block Scope vs Function Scope
Scope granularity determines how tightly variables are contained. JavaScript has two kinds of scopes for variable declarations: function scope (for var) and block scope (for let and const). The distinction matters because block-scoped variables cannot leak out of control-flow constructs like if, for, and while blocks, while var declarations can — leading to the classic loop bug and other surprising behaviors. Understanding this difference is prerequisite knowledge for writing predictable JavaScript.
var — Function Scoped
var declarations are scoped to the nearest function (or global), NOT to blocks like {}, if, for.
javascriptfunction example() {
if (true) {
var x = 10; // scoped to example(), not if block
let y = 20; // scoped to if block
const z = 30; // scoped to if block
}
console.log(x); // 10 — accessible!
console.log(y); // ReferenceError — not in scope
console.log(z); // ReferenceError — not in scope
}var Leaks Out of Blocks
Because var is function-scoped, a loop variable declared with var is accessible — and retains its final value — after the loop completes. This is the root cause of the notorious closure-in-loop bug where all callbacks share the same post-loop value of i. It is also why var leaks into the enclosing function scope even when you intend it to be local to a block.
javascriptfor (var i = 0; i < 3; i++) {
// i is function-scoped, shared across iterations
}
console.log(i); // 3 — leaked out!
for (let j = 0; j < 3; j++) {
// j is block-scoped to the for loop
}
console.log(j); // ReferenceError — not in scopelet and const — Block Scoped
let and const confine variables to the nearest enclosing block {}, whether that is a function body, a loop, an if statement, or a bare block. This makes it much easier to reason about where a variable is valid and prevents accidental reuse of loop variables in outer scopes. Prefer let/const over var in all modern JavaScript.
javascript{
let blockVar = 'only here';
const alsoBlockVar = 'here too';
}
// blockVar not accessible hereHoisting and Temporal Dead Zone
Hoisting is the engine behavior where variable and function declarations are processed before any code executes. It is not physical code movement — it is a property of how the JS engine sets up scope environments during the parsing phase. Understanding hoisting explains why var references before their declaration line are undefined (not a ReferenceError) and why function declarations are fully available anywhere in their scope.
var declarations are hoisted to the top of their function scope and initialized to undefined:
javascriptconsole.log(x); // undefined (not ReferenceError)
var x = 5;
console.log(x); // 5
// Equivalent to:
var x; // hoisted declaration
console.log(x); // undefined
x = 5;
console.log(x); // 5let and const are also hoisted, but NOT initialized — accessing them before their declaration is a Temporal Dead Zone (TDZ) error:
javascriptconsole.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 5;
// y exists in the scope (hoisted) but is in TDZ until the declaration lineHow Closures and Lexical Scope Work Together
Closures are only possible because of lexical scope. When a function is created, it captures a reference to its lexical environment (outer scope). That environment is defined by where the function appears in the source code.
javascriptfunction makeMultiplier(factor) {
// factor is in the lexical scope of the returned function
return (number) => number * factor;
}
const triple = makeMultiplier(3); // factor = 3 captured at DEFINITION
const quadruple = makeMultiplier(4); // factor = 4 captured at DEFINITION
triple(5); // 15 — factor is always 3 regardless of where triple is called
quadruple(5); // 20Shadowing
Shadowing occurs when an inner scope declares a variable with the same name as a variable in an outer scope. The inner declaration wins for all code within that inner scope, and the outer variable is temporarily inaccessible — not gone, just hidden. Shadowing is valid JavaScript and is sometimes intentional (e.g., a forEach callback parameter named index that shadows an outer index), but it can also be a source of bugs when accidental.
A variable in an inner scope can shadow (hide) a variable with the same name in an outer scope:
javascriptconst color = 'blue'; // outer
function paint() {
const color = 'red'; // shadows outer color in this function
console.log(color); // 'red'
}
paint();
console.log(color); // 'blue' — outer unchangedShadowing is valid but can be confusing. let/const with the same name as outer scope is usually intentional. Avoid shadowing outer variables unless you have a clear reason.
Global Scope vs Module Scope
The global scope is the outermost scope — the final stop in any scope chain lookup. In browsers it is window; in Node.js it is global. However, Node.js wraps every file in a module wrapper function before execution, meaning top-level variable declarations in a .js file are actually function-scoped to that wrapper, not truly global. This prevents accidental global pollution between modules and is why this at the top level of a Node.js file is {} (module.exports) rather than the global object.
In Node.js, each file is a module with its own module scope. Variables declared at the top level of a file are NOT global — they're module-scoped.
javascript// file-a.js
const x = 10; // module scope — NOT global
module.exports = x;
// file-b.js
const x = require('./file-a'); // gets 10
// No conflict with any x in file-b's own scopeTrue globals in Node.js must be explicitly attached:
javascriptglobal.myGlobal = 'I am truly global'; // accessible everywhere — bad practicePractical Implications
1. Variable Lookup is Determined at Write Time
javascriptfunction makeCounter() {
let count = 0;
return {
get: () => count, // lexically sees count from makeCounter
inc: () => ++count // same count reference
};
}2. Arrow Functions Inherit Outer this (Lexical this)
this inside an arrow function is determined lexically — not dynamically:
javascriptclass Timer {
constructor() {
this.seconds = 0;
}
start() {
// Arrow function uses 'this' from start()'s lexical scope (the class instance)
setInterval(() => {
this.seconds++; // works correctly
}, 1000);
// Regular function would have wrong 'this'
setInterval(function() {
this.seconds++; // 'this' is undefined in strict mode (or global)
}, 1000);
}
}Interview Questions
Q: What is the difference between lexical scope and dynamic scope? A: Lexical scope is determined at write time by the code structure. Dynamic scope is determined at runtime by the call stack. JavaScript uses lexical scope — where a function is defined determines what variables it can access, not where it is called from.
Q: What is the scope chain? A: The scope chain is the sequence of environments a variable lookup travels through — from the current function's scope outward through all enclosing scopes to global scope. If not found anywhere, it's a ReferenceError.
Q: What is the difference between var, let, and const scoping?
A: var is function-scoped (or global), let and const are block-scoped. var is hoisted and initialized to undefined, let/const are hoisted but in the TDZ until their declaration line.
Q: Can let/const be redeclared?
A: No. let and const cannot be redeclared in the same scope. var can be redeclared (second declaration is ignored). const additionally cannot be reassigned after initialization.