Scope
Scope determines where a variable is visible and accessible in your code. JavaScript resolves variable lookups using lexical (static) scoping: visibility is determined by where code is written, not where it's called from.
🎯 What you'll learn
- What Scope Controls
- The Three Kinds of Scope
- The Scope Chain
- Shadowing
- Lexical Scoping vs Dynamic Scoping
1What Scope Controls
Scope determines where a variable is visible and accessible in your code. JavaScript resolves variable lookups using lexical (static) scoping: visibility is determined by where code is written, not where it's called from.
2The Three Kinds of Scope
| Scope type | Created by | Applies to |
|---|---|---|
| Global scope | Code outside any function/block | var, let, const |
| Function scope | Any function | var, let, const |
| Block scope | { } — if, for, while, bare blocks | let, const only (not var) |
let global = "visible everywhere";
function outer() {
let functionScoped = "visible anywhere inside outer()";
if (true) {
let blockScoped = "visible only inside this if-block";
var notBlockScoped = "visible anywhere inside outer(), NOT just this block";
}
console.log(notBlockScoped); // works — var ignores block boundaries
// console.log(blockScoped); // ReferenceError
}3The Scope Chain
When a variable is referenced, the engine searches outward: current scope → enclosing scope → ... → global scope. The first match wins. If nothing matches, JavaScript throws a ReferenceError (in strict mode) or creates an accidental global (in non-strict mode, var/undeclared only).
let a = "outer";
function level1() {
let b = "level1";
function level2() {
let c = "level2";
console.log(a, b, c); // "outer level1 level2" — all reachable via the chain
}
level2();
}
level1();Scope chains are one-directional: inner scopes see outer variables, but outer scopes cannot see inner variables.
4Shadowing
An inner scope can declare a variable with the same name as an outer one. The inner variable shadows (hides) the outer one for the rest of that inner scope.
let x = "outer";
function shadow() {
let x = "inner";
console.log(x); // "inner" — the outer x is inaccessible here
}
shadow();
console.log(x); // "outer" — unaffected5Lexical Scoping vs Dynamic Scoping
JavaScript is lexically scoped: a function's access to variables is determined by where the function is defined, not where it's called.
let value = "global";
function printValue() {
console.log(value); // always looks up `value` from where printValue was DEFINED
}
function wrapper() {
let value = "local to wrapper";
printValue(); // still prints "global" — not "local to wrapper"
}
wrapper();This is the mechanism that makes closures possible (see the Closures topic).
6Module Scope
Each ES module (.mjs, or .js with "type": "module") has its own top-level scope. Top-level let/const/var/function declarations are not added to the global object and are invisible to other modules unless explicitly exported.
Under the HoodScope Chains Are Real Objects at Runtime▾
"Scope" isn't just a conceptual rule the engine follows loosely — at runtime, the scope chain is a real linked structure of objects the engine walks to resolve every variable reference.
Compile time: building the scope tree
Before running anything, V8's parser builds a static scope tree matching the nesting of your functions and blocks. For each scope, the compiler determines exactly which variables it declares and — critically — whether any of them are captured by an inner function (referenced from a nested closure).
Runtime: Context objects vs stack slots
This compile-time analysis feeds a per-variable decision:
| Variable's fate | Where it lives | Why |
|---|---|---|
| Never captured by any inner closure | a fast stack slot / CPU register | can be discarded the instant the function returns — no heap needed |
| Captured by at least one inner closure | a heap-allocated Context object | must outlive the function call, since the closure might run later |
function outer() {
let uncaptured = "fast"; // stays a stack slot — outer() returning discards it immediately
let captured = "slow-ish"; // promoted to a Context object — an inner function needs it later
return function inner() {
return captured; // this closure forces `captured` (and ONLY `captured`) onto the heap
};
}Only captured gets heap-allocated — uncaptured is never touched by the inner function, so the compiler leaves it as a cheap stack slot. This refines the common "closures keep the whole scope alive" simplification: modern engines are precise about which specific bindings actually need to survive, not the entire scope wholesale (full detail in the Closures topic).
The scope chain as a linked list of Context objects
Each Context object holds a fixed-size array of slots for its captured variables, plus a pointer to its parent Context — mirroring the lexical nesting exactly. Resolving a from three functions deep means: check the current Context's slots, if not found follow the parent pointer, repeat — literally walking a linked list of objects, one hop per enclosing scope, until found or the chain ends at the global object.
Context(level2) --parent--> Context(level1) --parent--> Context(global)
[c] [b] [a]This is why deeply nested scope access has a real (if usually negligible) runtime cost proportional to nesting depth — each unresolved lookup means one more Context hop, unlike a stack slot access, which is a single fixed-offset read.
Global scope is special: it's backed by a real object
Top-level var declarations (in non-module scripts) become actual properties on the global object (window/globalThis) — which is why they're visible as window.myVar. Top-level let/const are NOT added to the global object (they live in a separate, non-enumerable "script scope" record) — a real, observable difference, not just a stylistic one.
⚠️ Common Pitfalls
varinside loops — a singlevarbinding is shared across all iterations, which breaks closures capturing the loop variable (see Closures exercises).- Accidental globals — assigning to an undeclared variable in non-strict mode silently creates a global. Always use
"use strict"or ES modules (strict by default). - Confusing function scope with block scope — a
vardeclared inside aforloop is visible outside the loop entirely. - Shadowing bugs — redeclaring a parameter name or an outer variable name inside a nested function can silently hide the outer value.
📋 Quick Reference
Global scope
└─ Function scope (created per function call)
└─ Block scope (created per { }, let/const only)Lookup direction: inner → outer, never outer → inner.
🎤 Interview Q&A
Q1What kinds of scope exist in JavaScript?core▾
Global, module, function and block scope. Variables resolve by walking outward through enclosing scopes (lexical scoping), determined by where code is written, not where it's called.
- Do blocks create scope for var? No, only for let/const/class.
Q2What is lexical scoping?core▾
A function's access to variables is determined by its position in the source code: inner functions can access outer variables, which is the basis for closures.
- Dynamic scoping? JavaScript doesn't use it, except
thiswhich is call-site dependent.
Q3What's the classic var-in-loop bug?core▾
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) prints 3, 3, 3 because one function-scoped i is shared. let creates a fresh binding per iteration, printing 0, 1, 2.
- Old fix? An IIFE capturing i.
Q4What is shadowing?core▾
Declaring a variable in an inner scope with the same name as an outer one hides the outer one within that scope. It's legal but can cause confusion; linters can flag it.
- Can let shadow a parameter in the same scope? No, that's a redeclaration error.
Q5How do ES modules affect scope?senior▾
Each module has its own top-level scope; top-level declarations aren't global and must be exported to be shared. Modules run in strict mode.
- What is globalThis? A standard reference to the global object in any environment.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.