← back to the section

Scope answers the question "from where is this variable visible". In JavaScript it is lexical — determined by where the variable is written in the code, not by where someone tries to read it at runtime. It's a simple rule, but three historical storylines have piled up around it: var, hoisting, and the dead zone. Let's go through all of them.

Lexical nesting

Scopes nest like matryoshka dolls: a function sees its own variables, its parent function's variables, and globals — but not the other way around.

const outer = "outer";

function parent() {
  const middle = "middle";
  function child() {
    const inner = "inner";
    console.log(outer, middle, inner); // sees all three
  }
  child();
  // console.log(inner); // ReferenceError — you can't peek inside
}

Name lookup goes from the inside out: first the own scope, then the parent's, and so on up to the global one. The first name found wins — an inner variable shadows an outer one with the same name.

var versus let/const: function versus block

var is the pre-war way of declaring, and its scope is the entire function, not the block:

function demo() {
  if (true) {
    var v = 1;
    let l = 2;
  }
  console.log(v); // 1 — var "leaked" out of the block
  console.log(l); // ReferenceError — let lives only in the block
}

let and const are block-scoped: they live inside the nearest {}. The only difference between them: const forbids reassignment. An important nuance: const does not make an object immutable — you can mutate its contents; you just can't repoint the reference:

const arr = [1, 2];
arr.push(3);   // allowed — mutating contents
arr = [];      // TypeError — can't repoint

The modern rule: const by default, let when reassignment is genuinely needed, var — never.

Hoisting: why code sees variables "before" their declaration

Before execution, the engine scans the scope and "learns" about all declarations in advance — this is called hoisting. But declarations are hoisted differently:

console.log(v);  // undefined — var is hoisted and initialized to undefined
var v = 1;

console.log(l);  // ReferenceError! — let is hoisted but NOT initialized
let l = 2;

hi();            // "hi" — a function declaration is hoisted IN ITS ENTIRETY
function hi() { console.log("hi"); }

bye();           // TypeError — only the const variable was hoisted, not the value
const bye = () => console.log("bye");

The zone from the start of the block to the let/const line is called the temporal dead zone (TDZ): the variable already "exists", but reading it is an error. This is deliberate design: better to fail with a ReferenceError than to silently get undefined, as with var.

The practical takeaway: a function declaration can be called before it's declared (handy to put a file's main function at the top and helpers at the bottom); arrow functions in a const — only after.

The classic: var in a loop

The legendary puzzle that caught generations of developers:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 10);
}
// 3, 3, 3 — not 0, 1, 2!

Why: var i is one variable for the whole loop (function scope). All three callbacks closed over that single one, and by the time the timers fire the loop has finished and i === 3.

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 10);
}
// 0, 1, 2 — let creates a NEW variable for each iteration

This isn't timer magic — it's pure scope: let in the loop header means "a fresh variable on every turn". One line — two different worlds.

Modules and the global scope

Every ES module (a file with import/export) is its own scope: the file's top-level variables don't reach the global one. These days the global scope is polluted only by non-module scripts and assignments without declaration (in non-strict mode, x = 1 silently creates a global variable — yet another reason why modules and classes always run in strict mode).

In short

  • Scope is lexical: what matters is where the variable is written. Name lookup goes from the inside out; an inner variable shadows an outer one.
  • var is function-scoped, let/const are block-scoped. The rule: const by default, let when needed, var — never.
  • const forbids reassignment, but not mutating an object's contents.
  • Hoisting: a function declaration is available before its declaration in its entirety; let/const before the declaration line — a dead zone (ReferenceError); var gives a silent undefined.
  • var in a loop — one variable for all iterations, let — a new one for each. Hence 3, 3, 3 versus 0, 1, 2.
  • Closures — what happens when a function takes its scope along with it.
  • Type coercion — the previous topic in the series.
  • Event loop — why the setTimeout in the example fired after the loop finished.