JavaScript Lexical Scope: Compile-Time vs Runtime Resolution

JavaScript’s lexical scope is determined during the compile-time parsing phase when the engine builds a static scope tree, and at runtime the engine simply traverses this pre-built chain to resolve variable references.

The distinction between compile-time and runtime behavior in JavaScript lexical scoping is fundamental to understanding hoisting, closures, and the temporal dead zone. According to the You-Dont-Know-JS repository by getify, the JavaScript engine performs a parsing phase that establishes all lexical environments before any code executes.

What Is Lexical Scope in JavaScript?

Lexical scope (also called static scope) means that a variable's accessibility is determined by its physical position within the source code nesting. As documented in scope-closures/ch1.md of the You-Dont-Know-JS series, the scope chain is established by where functions and blocks are written, not where they are called.

Compile-Time: Building the Scope Chain

During the compile-time (parsing) phase, the JavaScript engine walks the source tree and creates environment records for each function, block, and catch construct. This process fixes the location of every identifier in the source code.

Environment Records and Identifier Binding

According to scope-closures/ch2.md, the parser records where each var, let, const, function, and class declaration appears. This binding process determines which lexical environment owns each variable before any code runs.

Hoisting and Declaration Processing

The compile-time phase handles hoisting by moving var and function declarations to the top of their containing scope. As shown in scope-closures/ch3.md, this means the scope tree contains these bindings immediately, though var variables remain uninitialized until their assignment executes.

Runtime: Resolving Variables Through the Static Chain

At runtime, the engine does not re-evaluate where variables were declared. Instead, it uses the pre-built lexical chain to resolve references.

Lexical Environment Lookup

When the engine encounters a variable reference, it searches the current Lexical Environment and walks outward through parent environments. As detailed in scope-closures/ch1.md, this lookup follows the static scope chain established during parsing, not the dynamic call stack.

Closures and Environment Capture

Closures demonstrate the compile-time vs runtime distinction clearly. According to scope-closures/ch4.md, when a function is defined, it captures a reference to its surrounding environment record (the storage for variables), not just the values. When the inner function executes later, it traverses this captured lexical chain to access the latest values:

function outer() {
  let count = 0;               // ← declared in outer’s lexical environment
  return function inner() {    // ← inner closes over count
    return ++count;            // ← runtime lookup walks to outer’s env
  };
}
const inc = outer();           // outer finishes, but its environment stays alive
console.log(inc()); // 1
console.log(inc()); // 2

Key Differences: Compile-Time vs Runtime

Understanding when specific scope behaviors occur helps debug hoisting issues and optimize performance:

  • Compile-time determines where variables live. The parser builds the scope tree, processes hoisting for var and functions, and establishes the lexical hierarchy as documented in scope-closures/ch2.md and scope-closures/ch3.md.
  • Runtime determines what values variables hold. The engine traverses the pre-built lexical chain to fetch values, creates closures by capturing environment references, and enforces the temporal dead zone (TDZ) for let/const as described in scope-closures/ch3.md.

Practical Examples from You-Dont-Know-JS

The You-Dont-Know-JS repository provides concrete demonstrations of these concepts across several chapters.

Var Hoisting vs TDZ

As shown in scope-closures/ch3.md, var hoisting occurs during compilation, while the TDZ is a runtime check:

// Compile-time: var a is hoisted to function scope
console.log(a); // undefined – declaration exists, initialization pending
var a = 10;

// Compile-time: let b binding created in block scope
// Runtime: TDZ prevents access before initialization line
console.log(b); // ReferenceError – TDZ
let b = 20;

Variable Shadowing

scope-closures/ch5.md demonstrates how compile-time scope nesting creates shadowing:

function foo() {
  let a = 1;
  function bar() {
    let a = 2; // Compile-time: new binding shadows outer 'a'
    console.log(a); // Runtime: finds inner 'a' (2)
  }
  bar();
  console.log(a); // Runtime: finds outer 'a' (1)
}

Named Function Expressions

According to scope-closures/ch6.md, named function expressions create a binding at compile-time that is only available inside the function:

const myFunc = function namedFunc() {
  // Compile-time: 'namedFunc' is bound in this function's scope only
  console.log(typeof namedFunc); // "function"
};
console.log(typeof namedFunc); // "undefined" – not available in outer scope

Summary

  • Lexical scope is static: JavaScript determines variable accessibility during the compile-time parsing phase by analyzing source code nesting, not runtime call patterns.
  • Compile-time builds the tree: The engine creates environment records, processes hoisting for var and functions, and establishes the lexical hierarchy as documented in scope-closures/ch1.md through scope-closures/ch3.md.
  • Runtime follows the chain: Execution resolves variables by traversing the pre-built lexical environment chain, captures closures by referencing environment records, and enforces the temporal dead zone for let/const.
  • Closures rely on this distinction: Inner functions capture references to outer environment records at definition time (compile-time), allowing access to those variables long after the outer function completes.

Frequently Asked Questions

What is the difference between lexical scope and dynamic scope?

Lexical scope (also called static scope) means that a variable's accessibility is determined by its physical location in the source code at compile-time. Dynamic scope would mean that variable access depends on the call stack at runtime. JavaScript uses exclusively lexical scoping, as established in scope-closures/ch1.md, meaning the scope chain is fixed during parsing and does not change based on how functions are invoked.

Does JavaScript re-evaluate scope chains at runtime?

No. Once the JavaScript engine completes the compile-time parsing phase, the lexical scope chain is immutable. At runtime, the engine only traverses this pre-built chain to resolve variable references. As demonstrated in scope-closures/ch4.md, when a closure captures an environment, it captures a reference to the compile-time environment record, not a snapshot of values, allowing it to access updated variable states through the static chain.

How does hoisting work if scope is determined at compile-time?

Hoisting is a compile-time behavior. During the parsing phase, the JavaScript engine scans the source code and moves var declarations and function declarations to the top of their containing scope, creating bindings in the environment record. As explained in scope-closures/ch2.md and scope-closures/ch3.md, this means the identifier exists throughout the scope at runtime, though var variables remain uninitialized (undefined) until their assignment line executes, while let and const remain in the temporal dead zone until initialization.

Why do closures work after the outer function returns?

Closures work because JavaScript's lexical scoping captures environment records, not just values. When an inner function is defined, it captures a reference to the outer function's lexical environment (created at compile-time). Even after the outer function finishes executing and its execution context pops off the call stack, the environment record remains alive in memory because the inner function maintains a reference to it. As shown in scope-closures/ch4.md, subsequent calls to the inner function traverse this captured lexical chain to access and modify the shared variables.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →