Lexical Scope

Mental model: Scope is decided by where you typed the code, not by who calls it.

Level: intermediate · about 12 minutes

A scope is a region of code where a name is visible. JavaScript has four kinds: global, module, function and block. The rule that ties them together is short: an inner scope can see outward; an outer scope cannot see inward.

const planet = 'Earth';

function greet() {
  const greeting = 'Hello';
  console.log(greeting, planet); // reaches outward for `planet`
}

greet();
// console.log(greeting); // ReferenceError: greeting is not defined

Predict what happens on the last line before you run it.

Block scope is real, and var ignores it

var — function scoped

function f() {
  if (true) {
    var x = 'leaks out';
  }
  console.log(x); // 'leaks out'
}

let / const — block scoped

function f() {
  if (true) {
    let y = 'stays in';
  }
  console.log(y); // ReferenceError
}

A block is any pair of curly braces: if, for, while, try, or a bare { }. var only cares about function boundaries, which is the single biggest reason it was replaced.

The scope chain

When you use a name, the engine looks in the current scope. Not there? It looks in the enclosing scope. It keeps walking outward until it finds the name or runs out of scopes, at which point you get a ReferenceError.

┌─ global ────────────────────────────────┐
│  const app = 'lab'                       │
│  ┌─ function outer() ─────────────────┐   │
│  │  const level = 1                   │   │
│  │  ┌─ block { } ──────────────────┐   │   │
│  │  │  const level = 2  ← shadows  │   │   │
│  │  │  console.log(level, app)     │   │   │
│  │  └──────────────────────────────┘   │   │
│  └────────────────────────────────────┘   │
└──────────────────────────────────────────┘
       resolution direction:  inner ──► outer
const level = 'global';

function outer() {
  const level = 'outer';   // shadows the global

  {
    const level = 'block'; // shadows outer
    console.log(level);    // 'block'
  }

  console.log(level);      // 'outer'
}

outer();
console.log(level);        // 'global'

Where scope surprises people

let count = 0;
function bump() { count = count + 1; }
bump(); bump();
console.log(count);

There is no let inside bump, so count resolves outward to the single shared binding. Both calls mutate the same variable. Had you written let count = count + 1 inside, you would have created a new binding and hit a TDZ error instead.

Try it yourself

Trace the chain

const app = 'JS Mastery Lab';

function outer() {
  const tool = 'editor';

  function inner() {
    const action = 'typing';
    console.log(action, tool, app); // three different scopes
  }

  inner();
}

outer();

Add a fourth nesting level. Then move const tool inside the block and see what breaks.

Exercises

Fix the leaking counter

This function should return the number of even values in an array, but the loop variable leaks and the total is wrong. Rewrite countEven so nothing escapes the block it belongs to, and the count is correct.

Check yourself

What does this log?
a ReferenceError — var a is function-scoped so it survives the block, but let b does not exist outside it. Referencing b — even inside typeof — throws a ReferenceError, because typeof does not protect you from an out-of-scope let.
Lexical scope means the scope of a name is determined by…
where the code is written — Lexical means "from the text". The nesting of your source code fixes the scope chain before anything runs. The call site is irrelevant — that is this, which follows completely different rules.

Common mistakes

  • Assuming var respects if and for blocks. It does not; only functions.
  • Shadowing a name accidentally and then debugging the wrong variable.
  • Believing typeof x is always safe. For a let/const in its TDZ, it throws.

Takeaways

  • Inner scopes see outward; outer scopes never see inward.
  • A block is any { }. let and const respect it, var does not.
  • Name resolution walks outward until it finds the binding or throws ReferenceError.