Engineering Notes
Home/Languages/JavaScript/Scope
Languages · JavaScript

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.

💻 Languages● Beginner⏱ 5 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 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 typeCreated byApplies to
Global scopeCode outside any function/blockvar, let, const
Function scopeAny functionvar, let, const
Block scope{ } — if, for, while, bare blockslet, const only (not var)
js
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).

Nested global, function, and block scopes with the lookup direction searching outward
Nested global, function, and block scopes with the lookup direction searching outward
js
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.

js
let x = "outer";
function shadow() {
  let x = "inner";
  console.log(x); // "inner" — the outer x is inaccessible here
}
shadow();
console.log(x); // "outer" — unaffected

5Lexical 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.

js
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 fateWhere it livesWhy
Never captured by any inner closurea fast stack slot / CPU registercan be discarded the instant the function returns — no heap needed
Captured by at least one inner closurea heap-allocated Context objectmust outlive the function call, since the closure might run later
js
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.

text
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

  1. var inside loops — a single var binding is shared across all iterations, which breaks closures capturing the loop variable (see Closures exercises).
  2. 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).
  3. Confusing function scope with block scope — a var declared inside a for loop is visible outside the loop entirely.
  4. Shadowing bugs — redeclaring a parameter name or an outer variable name inside a nested function can silently hide the outer value.

📋 Quick Reference

text
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • Dynamic scoping? JavaScript doesn't use it, except this which 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.