Everyone repeats the same line: "hoisting means declarations get moved to the top of the scope." It's a fine picture until you hit the actual interview question:
"Why does this
letthrow but thisvardoesn't?"
If declarations all moved to the top, both console.log(x); let x = 1; and console.log(y); var y = 1; should print undefined. Only one does. This one still trips me up if I read too fast, which is exactly why it gets asked. Here's what actually happens.
What hoisting actually is
Before the engine runs a scope, it scans it and registers every binding declared in it: every var, let, const, function, class. So the names all exist from the first line of the scope. Hoisting is real.
What differs is the starting value each kind of binding gets:
var-- registered and initialized toundefinedlet/const/class-- registered but left uninitializedfunctiondeclaration -- registered with its full body, ready to call
That one difference, initialized versus not, is the whole interview question.
var: hoisted and set to undefined
console.log(y); // undefined -- not a ReferenceError
var y = 1;
console.log(y); // 1
The engine treats this as: create y at the top set to undefined, then run the assignment when execution reaches that line. Reading it early is legal, you just get undefined. This is the source of a lot of "why is this undefined" confusion.
let and const: hoisted but not initialized
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 1;
x exists from the top of the scope, but it has no value yet, not even undefined. The stretch of code from the top of the scope down to the let x line is the Temporal Dead Zone. Any read or write of x in that stretch throws.

Why it's "temporal", not "positional"
The dead zone is about when execution happens, not where the code sits on the page:
function readIt() {
return value; // fine or throws depending on WHEN this runs
}
// readIt(); // would throw here: still in the TDZ
let value = 42;
readIt(); // 42 -- TDZ is over, the declaration has run
readIt is written above let value, but it only touches value when it is called. Call it before the let line runs and you are in the TDZ; call it after and you are fine.
The typeof tell
typeof neverDeclared; // "undefined" -- safe on a name that does not exist
typeof inTdz; // ReferenceError
let inTdz = 1;
typeof on a completely unknown name is safe and returns "undefined". typeof on a name that is declared but still in its TDZ throws. That difference is a favourite gotcha.
Functions hoist differently depending on how you wrote them
sayHi(); // "hi" -- function declaration, fully hoisted
function sayHi() { console.log("hi"); }
sayBye(); // TypeError: sayBye is not a function
var sayBye = () => console.log("bye");
A function declaration is hoisted name and body, so it is callable before its line. A function expression assigned to var only hoists the var (as undefined), so calling it early is calling undefined. A class declaration is hoisted like let, in the TDZ, so using it before its line throws.
The TDZ is a feature, not an annoyance
It exists on purpose:
- It turns "used a variable before I meant to" from a silent
undefinedbug into a loud error at the point of the mistake. - It makes
constcoherent, a value you can read before it is assigned is not really a constant. - It matches how people already picture code running, top to bottom.
The loop version of the question
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i)); // 3, 3, 3
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j)); // 0, 1, 2
}
var i is one binding shared by every iteration, so by the time the callbacks run, i is 3. let j gets a fresh binding per iteration, so each callback closes over its own j. This is the same closure mechanic from the closures question, just triggered by the choice of keyword.
Quick recap
- Everything is hoisted:
var,let,const,function,class. The names all exist from the top of the scope. varstarts asundefined;let/const/classstart uninitialized and throw if touched before their line, and that gap is the Temporal Dead Zone.- The TDZ is about execution reaching the declaration, not the line's position in the file.
functiondeclarations are fully hoisted and callable early; a function expression onvaris not.letper-iteration bindings are whyletfixes the classicfor+setTimeoutloop bug.
The answer that lands is not "let isn't hoisted." It is "both are hoisted, var is auto-initialized to undefined and let is not, so reading let early hits the Temporal Dead Zone."




Comments
No comments yet — be the first to share your thoughts.