Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 02 / 18 25 MIN

The language and the runtime

Scope, Hoisting, and the Temporal Dead Zone

How var, let and const differ in scope and initialisation, what hoisting really moves, and why the temporal dead zone is a feature rather than an inconvenience.

The first error most people meet in modern JavaScript is ReferenceError: Cannot access 'total' before initialization, and it appears on a line that looks correct: the let total = 0 is three lines further down the function, and reading a name before its declaration worked yesterday with var.

The two behaviours are not versions of the same rule. var and let have different scope, different initialisation and different failure modes, and the error message only makes sense once you know which one you are looking at. The good news is that the difference is small enough to hold in your head, and once it is there the error stops being mysterious and becomes a location.

This lesson covers the three declarations, what hoisting actually moves, why closures are the reason scope matters at all, and the loop bug that let was introduced to fix.

Every binding lives in a scope, and closures are what make that matter

A scope is a region of the program where a name can be used. When a function runs, it gets a fresh scope for its parameters and its local declarations, and that scope has a chain of parents ending at the module or global scope. Name resolution walks that chain outward and stops at the first match.

A closure is what you get when a function keeps a reference to a scope after the function that created it has returned. The inner function does not copy the variable, it holds the binding itself:

function counter() {
  let count = 0;
  return function () {
    count += 1;
    return count;
  };
}

const next = counter();
console.log(next(), next(), next());   // 1 2 3
console.log(typeof count);             // undefined — the scope is not reachable from here

count is a single binding that both the increment and the read share. Call counter() again and you get a different binding, independent of the first. This is the whole mechanism behind private state, and it is also the reason the loop bug below is so confusing: a closure captures the binding, so whatever the binding holds when the closure finally runs is what the closure sees.

var is function scoped, and let and const are block scoped

The scope of a var is the nearest enclosing function, or the module if there is no function. Braces do not stop it:

function demo() {
  if (true) {
    var leaks = "function scoped";
    let stays = "block scoped";
  }
  console.log(leaks);        // function scoped
  console.log(typeof stays); // undefined
}

leaks is visible after the if block because the block is not a scope for var. Reading stays after the block is not an error, it is a name that does not exist there, which is why typeof reports "undefined" rather than throwing.

let and const are scoped to the nearest enclosing block, and a block includes the body of an if, a for loop, or a bare { }. That is the entire difference in scope, and it matters mostly in loops. A loop that logs progress and then reports the final count is the case to watch:

function withVar(names) {
  for (var i = 0; i < names.length; i += 1) {
    // ... work
  }
  return i;                     // names.length, readable after the loop
}                               // (only when names.length is at least 1)

function withLet(names) {
  for (let i = 0; i < names.length; i += 1) {
    // ... work
  }
  return i;                     // ReferenceError: i is not defined
}

Neither behaviour is wrong. The var version leaks a counter into the function, which is occasionally useful and usually accidental. Reach for let when the counter is meant to stay inside the loop, which is almost always.

const prevents rebinding and nothing else

const is not "immutable". It means the binding cannot be reassigned. The value it points at is untouched by the guarantee:

const config = { retries: 3 };
config.retries = 5;
console.log(config.retries);   // 5 — the object was mutated, the binding was not

config = {};                   // TypeError: Assignment to constant variable.

You also have to initialise it. const total; on its own is a SyntaxError: Missing initializer in const declaration, and it is a parse-time error, so the file never runs. That catches the mistake early and I consider it a point in const's favour.

Redeclaration rules differ too. var allows the same name twice in the same scope, and the second declaration is ignored as a declaration while its assignment still happens. let and const reject a second declaration in the same scope with SyntaxError: Identifier 'b' has already been declared. That error is also raised before any code runs, which is why the tooling can catch it without executing the file.

varletconst
Scopefunction or moduleblockblock
Before its lineundefinedthrowsthrows
Reassignableyesyesno
Redeclarable in the same scopeyesnono
Initialiser requirednonoyes
Global object property at top level of a scriptyesnono

The last row is the one that surprises people migrating old browser code. In a classic <script> file, var total = 1 creates window.total and let total = 1 does not. In a Node module the file is already wrapped in a function, so neither creates a global property and the row is moot. I mention it because the difference shows up when a script is moved from a browser page into a module.

Hoisting moves declarations, not initialisations

Before a function or a module body runs, the runtime walks it and registers every declaration. That registration is what hoisting means, and what happens next depends on the kind of declaration.

A var is registered and immediately initialised to undefined. The assignment stays where you wrote it. That is why this prints undefined undefined instead of throwing:

console.log(typeof hoistedVar, hoistedVar);   // undefined undefined
var hoistedVar = 1;

A function declaration is registered together with its body, so it is callable before its line. This is not a trick to rely on; it does mean that mutual recursion between two functions at the top of a file works regardless of order. A function expression assigned to a var gets no such treatment, because the assignment has not run yet:

console.log(declared());      // works
function declared() { return "callable before its line"; }

console.log(assigned);        // undefined — the var is hoisted, the function is not
var assigned = function () { return "not yet"; };

A let or const is registered in its scope but left uninitialised. The name exists, reading it throws, and the interval between the start of the scope and the initialising line has a name: the temporal dead zone.

The temporal dead zone is the gap where a binding exists but has no value

The dead zone runs from the top of the enclosing block to the declaration. Inside it, any read throws ReferenceError: Cannot access 'x' before initialization. This applies to typeof as well, which is exactly the exception that does not exist for an undeclared name:

try {
  console.log(typeof blocked);
} catch (error) {
  console.log(error.name + ": " + error.message);
  // ReferenceError: Cannot access 'blocked' before initialization
}
let blocked = 1;

console.log(typeof neverDeclared);   // undefined — no binding anywhere, so no throw

That pair is worth remembering because it tells you which problem you have. Throwing means the binding exists and you are early. Returning undefined means there is no binding at all and you have a typo or a missing import.

The dead zone is a deliberate design choice, not an accident of implementation. With var, a use-before-declaration silently reads undefined and the program continues with a wrong value. With let, the same mistake stops at the line that made it. I would rather see the error than debug the symptom two modules later.

One consequence is easy to miss. The dead zone is per block, so a shadowed name is in its own dead zone inside the inner block even when an outer binding with the same name is initialised:

const total = 10;
function read() {
  try {
    console.log(total);   // throws: this block's `total` is not initialised yet
  } catch (error) {
    console.log(error.name);
  }
  const total = 20;
  return total;
}
console.log(read());      // ReferenceError then 20

let fixed the loop capture bug because each iteration gets its own binding

This is the bug that pushed let into the language. A loop that creates functions and calls them later returns the same value from every function, because var creates one binding for the whole loop:

function varLoop() {
  const out = [];
  for (var i = 0; i < 3; i += 1) {
    out.push(function () { return i; });
  }
  return out.map(function (fn) { return fn(); });
}

function letLoop() {
  const out = [];
  for (let i = 0; i < 3; i += 1) {
    out.push(function () { return i; });
  }
  return out.map(function (fn) { return fn(); });
}

console.log(varLoop());   // [ 3, 3, 3 ]
console.log(letLoop());   // [ 0, 1, 2 ]

The var version returns three threes because all three functions captured the same binding, and by the time the functions run the loop has finished and i is 3. The counter is not off by one here; the value is correct for the loop, and the closures are all looking at it.

The let version returns [0, 1, 2] because the specification gives each iteration of the loop its own binding, created by copying the previous iteration's value before the update step runs. That is why the fix is a one-word change and why the fix is not "wrap it in an IIFE", which was the workaround for years. If the copying is easier to hold onto, write the equivalent by hand:

const perIteration = [];
for (let i = 0; i < 3; i += 1) {
  const copy = i;
  perIteration.push(() => copy);
}
console.log(perIteration.map((fn) => fn()));   // [ 0, 1, 2 ]

Note what happens to a var declared inside the loop body rather than used as the loop variable. There is still one function-scoped binding, so the captured value is whatever the last assignment left behind. Switching the loop variable to let does not fix that; using const for the per-iteration value does.

My default for new code is const, let when the binding has to change, and var never. The one place I still write var is inside a generated file I do not control. Everything else is avoidable, and the linter rules that enforce this are on by default in most modern setups.

Lab 20 minutes

Both functions below are wrong, and they are wrong for different reasons. Run the file, write down what each one prints or throws before you run it, then fix each one with the smallest change that preserves the intended output.

function firstPass(names) {
  const lines = [];
  lines.push("counter starts at " + counter);
  var counter = names.length;
  lines.push("counter is now " + counter);

  const printers = [];
  for (var j = 0; j < 2; j += 1) {
    printers.push(function () { return "printer " + j; });
  }
  return { lines: lines, printers: printers };
}

function secondPass(names) {
  const lines = [];
  lines.push("counter starts at " + counter);
  let counter = names.length;
  return lines;
}

The intended behaviour is that firstPass reports the number of names and that its printers identify themselves as printer 0 and printer 1. The intended behaviour of secondPass is the same report, without the throw.

Then answer: why does fixing firstPass require two separate changes, and what does secondPass tell you about the difference between a name that is absent and a name that is early?

Show the solution and the reasoning

Here is the file as written, with both calls made so you can see each failure in isolation.

const report = firstPass(["ana", "bo"]);
console.log(report.lines.join("\n"));
console.log(report.printers.map(function (fn) { return fn(); }));

try {
  secondPass(["ana", "bo"]);
} catch (error) {
  console.log(error.name + ": " + error.message);
}
counter starts at undefined
counter is now 2
[ 'printer 2', 'printer 2' ]
ReferenceError: Cannot access 'counter' before initialization

firstPass fails quietly twice. The first line prints undefined because var counter is registered and initialised to undefined before the function body runs, so the read succeeds with the wrong value. The two printers both report printer 2 because they captured the single function-scoped j, and by the time they were called the loop had already exited with j at 2.

The smallest correct version uses const for the counter and the loop variable for the printers, and computes the value before the line that uses it:

function firstPass(names) {
  const lines = [];
  const counter = names.length;
  lines.push("counter starts at " + counter);
  lines.push("counter is now " + counter);

  const printers = [];
  for (let j = 0; j < 2; j += 1) {
    printers.push(function () { return "printer " + j; });
  }
  return { lines: lines, printers: printers };
}

Two changes because there are two separate bugs that happen to share a cause. Moving the declaration above the use removes the read-before-assignment. Changing var to let on the loop variable gives each iteration its own binding. You could fix the second one alone and the printers would be right while the first line stayed undefined; you could fix the first alone and get the counter right with three printers all saying printer 2. The shared cause is that var gave both bindings function scope and an undefined initial value. The shared fix is to stop using it.

const for the counter rather than let is a judgement call, not a requirement. Nothing reassigns it, so const says that out loud and stops the next edit from changing it by accident.

secondPass is the contrast that answers the second question. The name counter is not absent, it is early: let registered the binding at the start of the block and left it uninitialised, so the read throws ReferenceError: Cannot access 'counter' before initialization. An absent name gives the error counter is not defined instead, and typeof absentName gives "undefined" without throwing. Learning to read the two messages apart tells you immediately whether to look for a typo or for an ordering mistake. Fix secondPass by moving the declaration above the first lines.push; the dead zone ends at the declaration, so the second line is fine either way.

Progress

Mark this lesson done and it stays marked in this browser.