Chapter 4.5☕ 18 min read

Lexical vs Dynamic Scope: Why JS Chose Lexical

Likhne pe decide — chalane pe nahi. Yahi lexical scope hai aur yahi sahi tha.

0101 — Two Models: Lexical vs Dynamic

Lexical scope (also called static scope): the scope of a variable is determined at WRITE TIME — by where the code is physically written in the source. Dynamic scope: the scope is determined at CALL TIME — by who called the function and from where.

Languages using lexical scope: JavaScript, Python, Java, C, Rust, Go, TypeScript. Languages using dynamic scope: Bash, traditional Perl, older Lisp dialects. The vast majority of modern languages chose lexical scope — for good reason.

The key question: when a function looks up a variable, WHERE does it look?

// Lexical: look in the environment where it was DEFINED
// Dynamic: look in the environment where it was CALLED

This distinction sounds abstract but has massive consequences. Consider this test case:

var x = 'global';

function getValue() {
  return x; // which 'x' does this see?
}

function callWithLocal() {
  var x = 'local'; // local to callWithLocal
  return getValue(); // calls getValue from inside
}

// LEXICAL SCOPE (what JS actually does):
console.log(callWithLocal()); // 'global'
// getValue was DEFINED in global scope — sees global x
// The caller's local x is irrelevant

// IF JS had DYNAMIC SCOPE (hypothetical):
// callWithLocal() // would return 'local'
// because getValue was CALLED from callWithLocal's scope
// and dynamic scope looks at the call stack

// This is the fundamental difference!
V8 internals: V8 resolves variable lookups by following the chain of Lexical Environments created at PARSE TIME — not the call stack at runtime. When V8 compiles a function, it records which Lexical Environment the function was defined in. At runtime, variable lookups follow this pre-built chain. The call stack is completely irrelevant for variable lookup.
0202 — Why Lexical Scope Enables Closures

With lexical scope: a function always remembers its birth environment. The closure IS the function + its lexical birth environment. Without lexical scope, closures as we know them would not exist.

With dynamic scope: every function call would use the caller's scope — there is no "captured environment" to remember. Real-world proof: Bash has dynamic scope — no closures in Bash.

Lexical scope makes code PREDICTABLE — you can reason about variable access by reading the code. Dynamic scope makes code context-dependent — behavior changes based on who calls.

// Closures only make sense with lexical scope:
function makeAdder(n) {
  // n is in makeAdder's lexical environment
  return function(x) {
    return x + n; // refers to n from DEFINITION environment
  };
}

const add5 = makeAdder(5);

// LEXICAL: add5 looks up n in its birth environment (makeAdder's LE)
// n = 5 there — regardless of who calls add5
console.log(add5(10)); // 15 — correct!

// HYPOTHETICAL dynamic scope:
// When add5(10) is called from global scope,
// dynamic lookup would search the GLOBAL scope for n
// n is not in global scope → ReferenceError!
// (Or n would be whatever 'n' exists in the calling scope)

// Lexical scope = closures work as expected, everywhere, always.
// This is WHY JavaScript chose lexical scope.

function testDynamicScenario() {
  var n = 99; // in calling scope
  console.log(add5(10)); // lexical: 15, dynamic (hypothetical): 109
}
testDynamicScenario(); // 15 — lexical wins, n=99 is ignored
If you've ever wondered "why does this callback remember the outer variable?"
The answer is lexical scope. The callback was DEFINED inside a scope that had that variable. It doesn't matter that the callback runs later, elsewhere, or inside a different function. Lexical scope makes the birth environment permanent.
0303 — The with Statement: JS's Accidental Dynamic Scope

The with statement is JavaScript's accidental experiment with dynamic scope. with(obj) { } adds obj's properties to the scope chain for the block.

Variables inside the with block look up obj's properties FIRST — this is runtime scope modification. V8 cannot predict which variable will be found.

Result: TurboFan CANNOT optimize any function containing with.

// with statement — dynamic scope in action
var x = 'outer';

var obj = { x: 'object property' };

with(obj) {
  // Inside here, obj's properties are in scope
  console.log(x); // 'object property' — found in obj first!
}
console.log(x); // 'outer' — obj scope is gone

// The optimization killer:
function withExample(obj) {
  with(obj) {
    return x; // which x? V8 has NO IDEA at compile time!
    // If obj has .x: returns obj.x
    // If obj has no .x: returns outer x
    // Decision made at RUNTIME — TurboFan cannot optimize this!
  }
}
// V8 deoptimizes the ENTIRE function containing with
// (not just the with block — the whole function!)

// with is BANNED in strict mode:
// 'use strict';
// with(obj) { } // SyntaxError!

// Why V8 hates with:
// Every variable access becomes: "check obj first, then scope chain"
// This requires a dynamic lookup at every access
// V8's inline caches assume static scope — with breaks all of them
Performance impact: The with statement prevents V8 from generating efficient bytecode for the ENTIRE enclosing function — not just the with block. V8 must insert a dynamic scope check before every single variable access in that function. In benchmark tests, functions with with run 5-10x slower than equivalent code without it. This is one reason strict mode banning with gives a real performance win.
0404 — eval() and Dynamic Scope: Another Optimizer Killer

eval(string) executes a string as JavaScript code in the current scope. Non-strict eval can ADD new variables to the enclosing scope dynamically.

This means V8 cannot know at compile time which variables exist in scope — optimization dies. Any function containing eval() cannot be fully optimized.

Strict eval: creates its own scope — does NOT modify the enclosing scope. new Function(): creates a function in GLOBAL scope — safer, but still slow to parse.

// Non-strict eval — pollutes enclosing scope!
function dangerousEval(code) {
  eval(code); // can create variables in dangerousEval's scope!
  console.log(x); // may be from eval, may be outer — V8 doesn't know!
}
dangerousEval('var x = 42;'); // x is now in dangerousEval's scope!

// Strict eval — safe scope isolation
function saferEval(code) {
  'use strict';
  eval(code); // creates its OWN scope — doesn't leak
  // console.log(x); // ReferenceError — x from eval not here
}

// new Function — global scope, not local
const fn = new Function('a', 'b', 'return a + b');
console.log(fn(2, 3)); // 5
// fn has no access to local variables — only global scope
// Safer than eval for code generation

// When eval is justified (rare):
// 1. JSON.parse() alternative (never — use JSON.parse)
// 2. REPL-like environments (Codesandbox, Chrome DevTools)
// 3. Code coverage instrumentation tools
// 4. Some transpilers/compilers output eval (but minimize it)

// The performance cost:
function withEval(str) {
  eval(str); // TurboFan cannot optimize this function
  return x + 1; // which x? Only known at runtime
}

function withoutEval() {
  let x = 42;  // known at compile time
  return x + 1; // TurboFan: inline cache, SMI math — fast!
}
The rule: eval() in a function forces V8 to treat ALL variable accesses in that function as potentially dynamic. TurboFan cannot inline cache any variable lookup. Always isolate eval in its own small function and keep it away from performance-sensitive code. JSON.parse() is always faster and safer for data.
0505 — Lexical this vs Lexical Variables: Arrow Functions

Variables are always lexically scoped in JS — this has been true from the beginning. this is DYNAMICALLY scoped in regular functions — it depends on how the function is called.

Arrow functions make this LEXICALLY scoped — it inherits from the definition site, not the call site. This is a key design decision: regular functions had dynamic this for methods, arrow functions fixed the callback problem where this changed.

// Variables: always lexical — never changes
const name = 'global';

function showName() {
  console.log(name); // always 'global' — lexical, defined in global
}

function callShowName() {
  const name = 'local';
  showName(); // 'global' — showName doesn't see callShowName's name
}
callShowName(); // 'global' — lexical scope at work

// this: DYNAMIC in regular functions
const user = {
  name: 'Sai',
  greet: function() { return this.name; }
};
console.log(user.greet()); // 'Sai' — this = user (implicit binding)

const fn = user.greet;
console.log(fn()); // undefined — this = global (default binding)

// Arrow function: this is LEXICAL
const userWithArrow = {
  name: 'Sai',
  greet: () => 'Hello, ' + this.name // arrow, this from OUTER scope
};
console.log(userWithArrow.greet()); // 'Hello, undefined'
// Arrow greet was defined in object literal context
// this in object literal = enclosing scope's this = global
// Arrow methods should NOT be used for object methods!

// Arrow INSIDE regular method = lexical this correctly
const timer = {
  name: 'Timer',
  start: function() {
    setTimeout(() => {
      console.log(this.name); // 'Timer' — arrow captures this from start()
    }, 100);
  }
};
timer.start(); // 'Timer' ✅
The rule:
• Use regular functions for object methods (they need dynamic this to refer to the object)
• Use arrow functions for callbacks inside methods (they need lexical this to refer to the object)
• Mixing this up is the #1 source of "this is undefined" bugs in JavaScript

Lo kar liya — Key Points:

  • ✅ Lexical scope: variables resolved at WRITE time by where code is nested — JS always uses this
  • ✅ Dynamic scope: variables resolved at CALL time by who called the function — Bash uses this, JS does not
  • ✅ Closures REQUIRE lexical scope — a closure captures its birth environment, only meaningful with lexical scoping
  • with(obj) and non-strict eval() introduce runtime scope changes — V8 cannot optimize these functions
  • ✅ Arrow functions make this lexically scoped — inherits from definition site, not call site
  • ✅ Regular functions have dynamic this — the caller determines this. Arrow functions have lexical this — the definition site determines this
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase