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.
vargets a binding in the nearest function (or the global scope), initialized toundefined.let,constandclassget 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); // → 10The 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;
}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;
};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;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)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, likelet(though still initialized early, so you can call them before their line), and a lexical name can’t be shared with avar. 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;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
}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)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;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();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;
}
}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.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); // → trueIn 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)); // → trueGotcha
Freezing a
MaporSetdoes not freeze its contents.Object.freeze(new Map()).set('a', 1)works, because entries are internal data, not properties. The same goes for aconstloop counter:for (const i = 0; i < 3; i++)throws on the firsti++, whilefor (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.”