Ch. 1 · JavaScript

Hoisting, the TDZ and var vs let vs const

What the engine sets up before your code runs, why var reads as undefined while let throws, how the temporal dead zone works, and why const doesn't mean immutable.

~6 min readbeginner

Hoisting is usually explained as JavaScript moving declarations to the top of the file. That picture predicts a few outputs, but it breaks down the moment an interviewer asks why let throws where var quietly gives you undefined. Nothing is actually moved. Before a scope starts running, the engine scans it and creates a binding for every name it declares, and the kind of declaration decides what state that binding starts in. Once you can see that setup step, hoisting, the temporal dead zone and the differences between the three keywords all follow from one idea.

Two phases: setup, then execution

Every time the engine enters a scope (the script, a function call, a block), it works in two steps. Tutorials call the first one the creation phase; the spec calls it declaration instantiation. Only then does the execution phase run your statements top to bottom.

During the creation phase:

  • Function declarations get a binding initialized with the complete function.
  • var gets a binding in the nearest function (or the global scope), initialized to undefined.
  • let, const and class get a binding in the nearest block, left uninitialized.

So all of them are hoisted. The only question is what you get if you touch the name before its line runs.

console.log(score); // → undefined (declared and initialized to undefined)
var score = 10;
console.log(score); // → 10
JavaScript

The declaration was processed up front; the assignment score = 10 stays exactly where you wrote it.

Functions: declarations vs expressions

A function declaration is usable anywhere in its scope, even above the line that defines it:

console.log(add(2, 3)); // → 5

function add(a, b) {
  return a + b;
}
JavaScript

A function expression is just a value assigned to a variable, so it follows that variable’s rules:

console.log(typeof multiply); // → undefined
multiply(2, 3); // TypeError: multiply is not a function

var multiply = function (a, b) {
  return a * b;
};
JavaScript

With var, the name exists but holds undefined, and calling undefined is a TypeError. Declare it with const instead (the usual style for arrow functions) and you get a different error for a different reason:

divide(6, 3); // ReferenceError: Cannot access 'divide' before initialization

const divide = (a, b) => a / b;
JavaScript

When a function declaration and a var share a name, the function wins during setup and the assignment wins once execution reaches it:

// A classic script or CommonJS file (see the note below for ES modules)
console.log(typeof thing); // → function (the declaration wins at creation)

var thing = 'a string';
function thing() {}

console.log(typeof thing); // → string (the assignment ran)
JavaScript

Note

Paste the same code at the top level of an ES module and it never runs: SyntaxError: Identifier 'thing' has already been declared. At the top level of a module, function declarations count as lexical declarations, like let (though still initialized early, so you can call them before their line), and a lexical name can’t be shared with a var. Inside a function body, the example works in every environment.

Interview tip

Asked “why can I call a function before its definition?”, answer: “Function declarations are initialized with the whole function during the creation phase. Function expressions are just values assigned to a variable, so they follow that variable’s hoisting rules.”

let, const and the TDZ

A let or const binding exists from the start of its block, but it is uninitialized until the declaration line executes. The stretch in between is the temporal dead zone (TDZ). Any access in it throws:

console.log(total); // ReferenceError: Cannot access 'total' before initialization
let total = 0;
JavaScript

Compare that with a name that was never declared, which gives ReferenceError: nothing is not defined. The two messages tell you different things: “before initialization” means the binding exists, it just isn’t ready.

The clearest proof that let is hoisted is shadowing. If the inner color were not hoisted to the top of its block, the log would print “red”:

let color = 'red';

{
  console.log(color); // ReferenceError: Cannot access 'color' before initialization
  let color = 'blue'; // this binding is hoisted to the top of the block
}
JavaScript

The zone is temporal, not positional. Code that mentions the variable can appear above the declaration, as long as it runs afterwards:

function readLater() {
  return value; // fine to *mention* before the declaration
}

// readLater(); // calling it here would throw: value is still in the TDZ

let value = 42;
console.log(readLater()); // → 42 (called after initialization)
JavaScript

Even typeof, normally the safe way to probe a name, throws inside the TDZ:

console.log(typeof notDeclaredAnywhere); // → undefined
console.log(typeof declaredLater); // ReferenceError: Cannot access 'declaredLater' before initialization
let declaredLater = 1;
JavaScript

Block scope

var only respects function boundaries. let and const are scoped to the nearest pair of braces:

function demo() {
  if (true) {
    var fromVar = 'var';
    let fromLet = 'let';
  }
  console.log(fromVar); // → var (function-scoped)
  console.log(fromLet); // ReferenceError: fromLet is not defined
}
demo();
JavaScript

Block scope is also why let fixes the classic setTimeout loop: a for (let ...) loop gets a fresh binding for every iteration, while var shares one across the whole loop.

Classes are in the TDZ too

Class declarations are hoisted like let, not like functions:

const p = new Point(1, 2); // ReferenceError: Cannot access 'Point' before initialization

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}
JavaScript

A class expression stored in a var behaves like any other var: before the assignment it is undefined, so new Shape() fails with TypeError: Shape is not a constructor.

const is not immutability

const means the binding can never be reassigned. It says nothing about the value it points to:

const config = { debug: false };
config.debug = true; // fine: mutating the object, not the binding
console.log(config.debug); // → true

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

A const also needs a value up front: const x; is a SyntaxError. To stop an object from changing, freeze it. Object.freeze makes existing properties read-only and blocks adding or removing properties, but it is shallow:

// ES module, so strict mode: writes to frozen objects throw
const settings = Object.freeze({ theme: 'dark', flags: { beta: false } });

try {
  settings.theme = 'light';
} catch (e) {
  console.log(e.message); // → Cannot assign to read only property 'theme' of object '#<Object>'
}

settings.flags.beta = true; // no error: freeze is shallow
console.log(settings.flags.beta); // → true
JavaScript

In sloppy mode the write to settings.theme fails silently instead of throwing. For deep immutability, freeze recursively:

function deepFreeze(obj) {
  Object.freeze(obj);
  for (const value of Object.values(obj)) {
    if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
      deepFreeze(value); // the isFrozen check also stops infinite loops on cycles
    }
  }
  return obj;
}

const settings = deepFreeze({ theme: 'dark', flags: { beta: false } });
console.log(Object.isFrozen(settings.flags)); // → true
JavaScript

Gotcha

Freezing a Map or Set does not freeze its contents. Object.freeze(new Map()).set('a', 1) works, because entries are internal data, not properties. The same goes for a const loop counter: for (const i = 0; i < 3; i++) throws on the first i++, while for (const item of list) is fine.

var vs let vs const at a glance

var let const
Scope Function (or global) Block Block
Hoisted Yes, as undefined Yes, uninitialized (TDZ) Yes, uninitialized (TDZ)
Access before declaration undefined ReferenceError ReferenceError
Redeclare in the same scope Allowed SyntaxError SyntaxError
Reassign Yes Yes No (TypeError)
Initializer required No No Yes
New binding per loop iteration No Yes Yes (for...of / for...in)
Top-level in a classic script creates a window property Yes No No

Two rows deserve a comment. Redeclaring a let is an early error: the whole script fails to parse, so not even the lines above it run. The last row only applies to classic browser scripts; in ES modules and Node CommonJS files, a top-level var stays local to the module.

The interview answer

“Before a scope runs, the engine creates a binding for everything declared in it; that’s hoisting. What differs is initialization. Function declarations are initialized with the whole function, so you can call them early. var is initialized to undefined, so an early read gives undefined. let, const and class are created but left uninitialized, and touching them before the declaration runs throws a ReferenceError. That window is the temporal dead zone.

Beyond hoisting, var is function-scoped and can be redeclared, while let and const are block-scoped and can’t. const only prevents reassigning the binding; the object it points to is still mutable unless you freeze it, and Object.freeze is shallow. My default is const, let when I need to reassign, and no var.”

More in JavaScript

read ✓JavaScript · mid

Closures, Scope & the Classic setTimeout Loop

What lexical scope and closures really are, why the var + setTimeout loop prints 3 3 3, three ways to fix it, and where closures earn their keep in real code.

~6 min readread →
read ✓JavaScript · hard

The Event Loop: Microtasks vs Macrotasks

How the call stack, task queue and microtask queue fit together, where rendering happens, how async/await schedules work, and output puzzles with answers.

~7 min readread →
esc