Skip to main content
Being Idea Innovations
What Is Lexical Scope in JavaScript? A Clear Explanation with Examples
Back to Blog

What Is Lexical Scope in JavaScript? A Clear Explanation with Examples

25 April 20236 min readJavascript
Share:

Lexical scope is the rule that determines which variables a piece of JavaScript can access, based on where that code is physically written in your source file. It is decided when you write the code, not when you run it.

That single idea explains closures, hoisting quirks, the temporal dead zone, and most "why is this variable undefined?" bugs. It is also one of the most common JavaScript interview topics. Here is a proper explanation.

The core idea

"Lexical" means "relating to the text". Your scope is determined by the nesting of the code as written:

const outer = 'I am outer';

function parent() {
  const middle = 'I am middle';

  function child() {
    const inner = 'I am inner';
    console.log(outer);   // works — found further up
    console.log(middle);  // works — found in parent
    console.log(inner);   // works — right here
  }

  child();
  console.log(inner);     // ReferenceError: inner is not defined
}

child can see everything in parent and everything at the top level, because it is written inside them. parent cannot see inside child. Scope looks outward, never inward.

The scope chain

When JavaScript hits a variable name, it searches the current scope. Not found? It checks the enclosing scope. Then the one enclosing that, up to the global scope. First match wins; if nothing matches you get a ReferenceError.

const name = 'global';

function a() {
  const name = 'a';
  function b() {
    // No `name` here, so JS walks outward and finds a's.
    console.log(name);   // "a"
  }
  b();
}

Because the search stops at the first match, an inner declaration shadows an outer one of the same name. That is legal and often useful, but shadowing accidentally is a reliable source of confusion.

Lexical scope is about where code is written, not where it is called

This is the point that trips people up, so it is worth an example that isolates it:

const value = 'global';

function show() {
  console.log(value);
}

function run() {
  const value = 'local to run';
  show();   // logs "global", NOT "local to run"
}

run();

show is called from inside run, but it was written at the top level. Its scope chain was fixed the moment it was defined, so it looks outward from where it sits in the source. The call site is irrelevant.

The alternative — resolving variables based on the call stack — is called dynamic scope, and JavaScript does not use it. (Bash does, which is why shell variables behave so differently.)

Function scope vs block scope

var is scoped to the nearest function. let and const are scoped to the nearest block — any pair of curly braces.

function demo() {
  if (true) {
    var  functionScoped = 'var';
    let  blockScoped    = 'let';
  }

  console.log(functionScoped);  // "var" — the if-block did not contain it
  console.log(blockScoped);     // ReferenceError
}

This is why the classic loop bug happens:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 3, 3, 3

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 0, 1, 2

With var there is exactly one i, shared by all three callbacks; by the time they run, the loop has finished and i is 3. With let, the language creates a fresh binding for each iteration, so each callback closes over its own copy.

Closures: lexical scope that outlives its function

A closure is what you get when a function keeps access to its lexical scope even after the outer function has returned.

function makeCounter() {
  let count = 0;                 // not accessible from outside

  return {
    increment: () => ++count,
    current:   () => count,
  };
}

const counter = makeCounter();
counter.increment();   // 1
counter.increment();   // 2
counter.current();     // 2
counter.count;         // undefined — genuinely private

makeCounter has returned, yet count is still alive because the returned functions were written inside its scope and still reference it. This is the standard way to get genuinely private state in JavaScript, and it is the mechanism behind module patterns, memoisation, event handlers that remember configuration, and React hooks.

One practical warning: a closure keeps its entire enclosing scope reachable, not just the variables it uses. Holding a closure over a scope that captured a large array or a DOM node keeps that memory alive. This is a common source of leaks in long-running applications.

Hoisting and the temporal dead zone

Declarations are registered in their scope before any code executes. How they behave before their line differs:

console.log(withVar);    // undefined — declared, not yet assigned
console.log(withLet);    // ReferenceError: Cannot access before initialization

var withVar = 1;
let withLet = 2;

var is initialised to undefined at scope entry, which is why reading it early gives you a silent undefined instead of an error. let and const are hoisted too, but stay uninitialised until execution reaches their declaration. That gap is the temporal dead zone, and touching a variable inside it throws.

The error is a feature. A loud ReferenceError at the exact line is far easier to debug than an undefined that quietly propagates through three function calls.

Function declarations are hoisted completely and are callable before their definition; function expressions follow the rules of whichever keyword declares them.

declared();      // works
expressed();     // TypeError: expressed is not a function

function declared() { return 'ok'; }
var expressed = function () { return 'ok'; };

Module scope

Anything declared at the top level of an ES module is scoped to that module, not to the global object. Two modules can each declare const config without colliding, and neither becomes a property of window.

In a classic script, by contrast, a top-level var does attach to the global object. That difference is a large part of why modules replaced the IIFE-wrapping conventions that older codebases relied on.

The exception: `this` is not lexical

Everything above describes variables. this plays by entirely different rules — in a normal function it is determined by how the function is called, which is dynamic scoping in all but name.

const user = {
  name: 'Ada',
  greetBroken() {
    setTimeout(function () {
      console.log(this.name);   // undefined — `this` is not `user` here
    }, 100);
  },
  greetFixed() {
    setTimeout(() => {
      console.log(this.name);   // "Ada"
    }, 100);
  },
};

Arrow functions are the exception to the exception: they have no this of their own and instead take it from the enclosing lexical scope. That is the actual reason arrow functions solved the old const self = this workaround — not brevity.

Quick reference

  • Scope is set by where code is written, fixed at author time.
  • Lookups travel outward only, stopping at the first match.
  • var → function scope. let and const → block scope.
  • A closure is a function that retains its lexical scope after the outer call has returned.
  • let and const hoist into a temporal dead zone and throw if read early.
  • this does not follow lexical scope — except in arrow functions, which inherit it.

Once lexical scope clicks, a surprising amount of JavaScript stops being mysterious: closures become obvious rather than magical, the var loop bug explains itself, and the temporal dead zone reads as a sensible safety feature rather than an arbitrary error.

Share:

Written by

Amit Verma

Founder / Senior Software Engineer

Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.

More articles by Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers