JavaScript · Core language
`var` / `let` / `const`, scope, hoisting, TDZ
⚡ TL;DR — All declarations are hoisted; the difference is what they're initialised to.
varis initialised toundefinedat scope entry,functionto the whole function, andlet/constto nothing at all — which is what the Temporal Dead Zone is.
🧠 Mental model
Stop thinking of hoisting as "code moves to the top". Nothing moves. When the engine enters a scope it creates the environment record first, binding every declared name before a single statement runs. Hoisting is just the observable side-effect of that setup pass.
The only question that matters is: what is the binding's value between scope entry and the line that declares it?
| Declaration | Hoisted? | Value before its line | Scope |
|---|---|---|---|
var x |
yes | undefined |
function |
function f(){} |
yes | the full function | block (in strict mode) |
let x / class X |
yes | uninitialised → throws | block |
const x |
yes | uninitialised → throws | block |
So let is hoisted. Anyone who tells you "let isn't hoisted" is describing the symptom, not the mechanism — and an interviewer who knows the spec will notice.
⚙️ How it actually works
Every scope has an Environment Record — a map of names to binding slots. On entry:
varand function declarations are instantiated and initialised (undefined/ the function object). This is whyvarreads don't throw.let,constandclassare instantiated but left uninitialised. The slot exists, but it's marked "not yet initialised".- Execution begins. Touching an uninitialised slot throws
ReferenceError: Cannot access 'x' before initialization.
That window — slot exists, but reading it throws — is the Temporal Dead Zone. It's temporal, not spatial: it's about time-of-execution, not position in the file.
function f() {
console.log(x); // ReferenceError — TDZ, NOT "undefined"
let x = 1;
}
The TDZ is deliberate. If let defaulted to undefined, const would have to be assignable once at declaration and undefined before it — making const observably mutable. The TDZ is the price of making const mean something.
Crucially, the binding is created — so it shadows outer scopes even inside the TDZ:
let y = 'outer';
{
console.log(y); // ReferenceError — NOT 'outer'
let y = 'inner';
}
That example is the single fastest way to prove you understand the mechanism rather than the folklore.
const is not immutability. It freezes the binding, not the value. const a = []; a.push(1) is fine. Only reassignment (a = []) throws.
💻 Code
The classic loop question — and why it behaves that way:
// ❌ `var` has ONE binding for the whole function. All three closures share it.
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3 3 3
// ✅ `let` gets a FRESH binding PER ITERATION, copied forward each step.
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 0 1 2
The let version isn't magic scoping — the spec literally performs CreatePerIterationEnvironment, copying the value into a new binding each turn. That's why for (const i = 0; ...) throws on increment but for (const x of arr) is fine: for...of creates a new binding per iteration and never increments it.
Redeclaration rules, which output questions love:
var a = 1; var a = 2; // ✅ fine
let b = 1; let b = 2; // ❌ SyntaxError — thrown at PARSE time, before ANY code runs
const c; // ❌ SyntaxError — const must be initialised
Note that SyntaxError means the entire script/module fails to run — not that it fails at that line.
⚖️ Trade-offs
- Default to
const, useletwhen you must reassign, never usevar. Not dogma:constcommunicates "this name never changes", which is real information for the next reader and lets engines and linters reason more tightly. - The one honest argument for
varis function-scoped hoisting for a variable assigned in atryand read after it. The right answer there is to restructure, not reach forvar. - Block scoping costs nothing at runtime — engines resolve most bindings statically. Don't micro-optimise this.
varisn't a bug, it's a legacy design where function was the only scope unit. Understand it because you'll read old code and be asked about it, not because you'll write it.
💣 Gotchas interviewers probe
- "Is
lethoisted?" — The trap answer is "no". The correct answer: yes, but uninitialised, which is the TDZ. This is the single most common gotcha in this topic. - TDZ shadows the outer scope.
let y='outer'; { console.log(y); let y; }throws — it does not print'outer'. typeofis not safe anymore.typeof undeclared→"undefined", buttypeof xinsidex's TDZ throws. The one guaranteed-safetypeofwas quietly broken by ES6, on purpose.const≠ frozen. Objects behind aconstare fully mutable. Reach forObject.freeze()(shallow!) if you actually mean immutable.- Function declarations in blocks are block-scoped in strict mode/modules, but legacy web semantics hoist them to the function scope in sloppy mode. Use
const fn = () => {}and the ambiguity disappears. varat top level of a script creates a property onglobalThis;let/constdo not.var g=1; globalThis.g // 1vslet g=1; globalThis.g // undefined.- Redeclaration errors are
SyntaxErrors — they fire before execution, so no earlierconsole.login the file will print.
🎯 Say this in the interview
"All three are hoisted — the difference is initialisation. When the engine enters a scope it creates every binding first:
vargets initialised toundefined, function declarations get their function object, andlet/const/classget created but left uninitialised. Reading one in that window throws aReferenceError— that's the Temporal Dead Zone. So 'let isn't hoisted' is wrong; it's hoisted but unreachable, which you can prove because aletin a block shadows an outer variable even before its declaration line. The TDZ exists soconstcan actually mean 'never observably unassigned'. In practice I default toconst, useletwhen I need reassignment, and nevervar— mainly becausevar's single function-scoped binding is what makes the classicsetTimeout-in-a-loop print3 3 3."
🔗 Go deeper
- javascript.info — Variable scope, closure — lexical environments explained with the right diagrams.
- MDN — let — the TDZ described precisely, including the
typeofcaveat. - MDN — Hoisting — short, and correctly frames it as initialisation rather than movement.
- ECMAScript spec — Declarative Environment Records — where "uninitialised binding" is actually defined.