Lesson 8 · 18 min read
let, const, var and the Temporal Dead Zone
The full picture of the three declaration keywords: every axis they differ on, function scope versus block scope (and what loops do), exactly where the TDZ starts and ends and why it is temporal rather than positional, the places it hides, redeclaration, errors that stop your file before it runs, and the one exception to "typeof is always safe".
On this page
- Concept
- Key words in plain English
- Why this matters
- The blueprint vs. the locked room — an analogy
- The three keywords side by side
- Function scope vs. block scope — and what loops do
- The TDZ, precisely: where it starts and where it ends
- Why "temporal" — it is about time, not position
- The TDZ in disguise: shadowing and default parameters
- Redeclaration: var allows it, let and const don't
- The parse-time twist: why some errors crash everything
- The five error types — full taxonomy
- The typeof exception: TDZ breaks the safety net
- const: a frozen binding, not a frozen value
- Minimizing your own TDZ window — and which keyword to use
- Real example — traced line by line
- See it live — console & DevTools
- Common misconceptions
- Interview questions
Concept
Lesson 3 gave you the headline: var, let and const are all hoisted, and the difference is what happens next — var starts as undefined, let/const start locked in the Temporal Dead Zone (TDZ). This lesson is the full picture. Every axis on which the three keywords differ, in one grid. Function scope versus block scope, including the place where it matters most: loops. Exactly which lines the TDZ covers, why it is called temporal rather than positional, and the two places it hides where you would not expect it. Then the parts that confuse even experienced developers: redeclaration, errors that stop your whole file before a single line runs, the five distinct error messages, and the one real exception to “typeof is always safe”.
One mental model carries the whole lesson: a declaration reserves a slot in the memory phase; the keyword decides what the slot starts as, which braces own it, and what you are allowed to do to it afterwards. Everything below is a consequence of those three decisions.
Key Words In Plain English
Short definitions first, so every later section is easier to read.
- Variable is a named box in memory that holds a value.
let age = 7makes a box calledagethat holds 7. - Binding is the link between a name and its box. A variable is really a binding: the name
agepoints to a value. - Scope is the part of the code where a name can be used. Outside its scope, the name does not exist.
- Block is any code inside a pair of curly braces
{ }, for example the body of anifor afor. - Hoisting is the engine reserving memory for declarations before it runs any line of the code. It does not move your code. It only means the names are known in advance.
- Initialise means give a binding its first value. Until then it is empty (
vargivesundefined) or locked (let/const). - Temporal Dead Zone (TDZ) is the period of time between a
let/constname being reserved and its declaration line running. In that period the name exists but reading or writing it throws an error. - Shadowing is when an inner scope declares a name that already exists in an outer scope. Inside, the inner one hides the outer one.
- Parsing is the engine reading your source text and checking that it is valid JavaScript, before anything runs. Runtime is the time when the code actually runs, line by line.
- SyntaxError is an error for text that is not valid JavaScript. It is found while parsing. ReferenceError is an error for using a name that cannot be used right now. TypeError is an error for doing something a value does not allow, such as changing a
const. These two are found at runtime. typeofis an operator that returns the type of a value as a string, such as"number". For a name that was never declared it returns"undefined"and does not throw.- Closure is a function that remembers the variables from the place where it was created, even after that place has finished running.
- Primitive is a simple value that cannot be changed in place: a number, string, boolean,
undefined,null, symbol or bigint. An object (including arrays) is a value with properties that can be changed. Object.freezeis a built-in function that stops an object from getting new, changed or removed properties. It only protects the top level.
Why This Matters
- “Which keyword should I use?” is answered properly only once you can see all the differences at once — scope, initial value, reassignment, redeclaration and the global object — rather than the one or two you happen to remember.
- The loop case (
for (var i…)versusfor (let i…)) is behind the single most famous JavaScript interview question, which Lesson 11 traces in full. This lesson gives you the memory picture it depends on. - Redeclaration bugs are common when refactoring — knowing exactly which combinations throw and which don't saves real debugging time.
- Understanding that some errors are caught before your code even starts running explains why “but the console.log above the error never even printed” happens.
- The five distinct error types are a favourite interview drill — knowing which one applies to which mistake proves real understanding, not memorized keywords.
- The
typeofTDZ exception is a real pitfall that surprises even experienced developers.
The Blueprint vs. The Locked Room — An Analogy
Imagine a building inspector reviewing the hotel's blueprint before construction even starts. If the blueprint itself has a real mistake — say, the same room number used twice on the same floor — the inspector rejects it outright. Construction never begins. Not even the perfectly fine lobby on that same blueprint gets built that day, because the whole plan was thrown out before a single brick was laid.
Now imagine the blueprint was fine, construction went ahead, and the hotel opened. A guest tries a specific room before it's officially ready and finds the door locked — that's the TDZ. Everything else in the hotel is running completely normally; only that one specific room is temporarily off-limits.
One is a plan that never gets approved at all. The other is a working building with one locked door. JavaScript treats these as two entirely different categories of error.
The Three Keywords Side By Side
Before going deep on any one row, here is the whole table. Read it top to bottom once now; the rest of the lesson explains the rows that need explaining.
| var | let | const | |
|---|---|---|---|
| Hoisted? | yes | yes | yes |
| Value before its line | undefined | 🔒 TDZ (throws) | 🔒 TDZ (throws) |
| Scope | function (or global) | block { } | block { } |
| Must initialise on declaration? | no | no (defaults to undefined) | yes — SyntaxError otherwise |
| Reassign later? | yes | yes | no — TypeError |
| Redeclare in same scope? | yes | no — SyntaxError | no — SyntaxError |
| Top-level: property of window? | yes | no | no |
| Fresh binding per loop iteration? | no — one shared slot | yes | yes (no i++ though) |
| Memory record (global) | Object Environment Record | Declarative Environment Record | Declarative Environment Record |
Three things to notice. First, the hoisting row is identical — the engine reserves a slot for all three in the memory phase (Lesson 1). Second, the differences split cleanly into two families: var on one side, let/const on the other, with const adding exactly two extra rules on top of let (must initialise, cannot reassign). Third, the last row is the mechanism behind several of the others — let/const live in a private record the engine controls directly (Lesson 3), which is what makes a “locked” state possible at all.
Function Scope vs. Block Scope — And What Loops Do
var is function-scoped: it belongs to the nearest enclosing function (or to Global if there is none), and it ignores every other pair of braces on the way. let and const are block-scoped: they belong to the nearest enclosing { } of any kind — if, for, while, a bare block, a function body.
function checkIn() {
if (true) {
var floor = 3; // belongs to checkIn, not to the if
let wing = "north"; // belongs to the if block only
const suite = false; // belongs to the if block only
}
console.log(floor); // 3
console.log(typeof wing); // "undefined" — gone with the block
}Inside checkIn, floor is hoisted to the function's own memory during the memory phase, exactly as if it had been written on the first line of the function — the if braces are invisible to it. wing and suite are created when the block is entered and destroyed when it closes; by line 8 they do not exist anywhere in the scope chain (Lesson 7).
The place this matters most is a for loop, because a loop is a block that runs many times:
for (var i = 0; i < 3; i++) {
console.log(i);
}
console.log(i); // 3 — leaked out of the loopfor (let i = 0; i < 3; i++) {
console.log(i);
}
console.log(i); // ReferenceError: i is not definedThe visible difference is the last line. The important difference is invisible: with var there is one i for the whole loop, mutated three times. With let, the engine creates a fresh binding for every iteration — three separate slots that happen to share a name — and throws them all away when the loop ends.
var i is hoisted to the enclosing function (here, Global): one slot, undefined. let i belongs to the loop itself — nothing exists outside the loop yet.
1for (var i = 0; i < 3; i++) {2console.log(i);3}4console.log(i); // 3
1for (let i = 0; i < 3; i++) {2console.log(i);3}4console.log(i); // ReferenceError
Right now the three let slots look like a technicality — the console output is identical. It stops being a technicality the moment something inside the loop body remembers i for later, such as a setTimeout callback. That is the var-prints-3, 3, 3 interview classic, and it is entirely explained by “one slot versus three slots”. Lesson 11 traces it step by step; bank the picture now.
The TDZ, Precisely: Where It Starts And Where It Ends
“Before the declaration” is the informal description. The precise one has two ends:
- It starts at the top of the enclosing scope — the opening brace of the block (or the first line of the function or file) — not at the line above the declaration. Every line of the scope that comes before the declaration is in the zone.
- It ends when the declaration is evaluated — when execution actually reaches and runs the
let/constline. Forletwith no initialiser, that moment sets the binding toundefined: unlocked, merely empty, readable without error.
function checkIn() {
// TDZ for roomNumber starts here — the top of the scope
console.log(typeof roomNumber); // ReferenceError
let roomNumber; // ← declaration evaluated: TDZ ends
console.log(roomNumber); // undefined — unlocked, just empty
roomNumber = 101;
}1·function checkIn() {2🔒 dead zone// TDZ for roomNumber starts here — the top of the scope3🔒 dead zoneconsole.log(typeof roomNumber); // ReferenceError4🔒 dead zonehelper(); // ReferenceError if helper reads it5unlocklet roomNumber; // ← declaration evaluated: TDZ ends6usableconsole.log(roomNumber); // undefined — unlocked, just empty7usableroomNumber = 101;8·}
Line 4 (let roomNumber;) is the detail people miss. let roomNumber; is not “still uninitialised” — evaluating the declaration is the initialisation, to undefined. After that line, roomNumber behaves like any empty variable. The lock is tied to the declaration being reached, not to a value being provided.
Why “Temporal” — It Is About Time, Not Position
The name is precise. The dead zone is a stretch of time during execution — from the scope being entered until the declaration runs — not a stretch of lines in the file. The cleanest proof is a function that reads the variable, written above the declaration both times, called twice:
function readRoom() {
return roomNumber; // written ABOVE the let, both times
}
readRoom(); // ① ReferenceError — called before line 6 has run
let roomNumber = 101;
readRoom(); // ② 101 — same function, called after line 6 ranreadRoom is hoisted in full. roomNumber is hoisted into the TDZ — locked until line 6 is evaluated.
1function readRoom() {2return roomNumber; // written ABOVE the let, both times3}45readRoom(); // ① called before line 6 runs6let roomNumber = 101;7readRoom(); // ② called after line 6 ran
Same function, same line 2, same position above the let. Call ① throws, call ② returns 101. Nothing about the code's layout changed between them — only when the read happened relative to the declaration being evaluated. That is the whole meaning of “temporal”, and it is why a lexical (position-based) rule like scope (Lesson 7) and a temporal rule like the TDZ can coexist: scope decides which binding a name refers to; the TDZ decides whether that binding is ready yet.
The TDZ In Disguise: Shadowing And Default Parameters
Two places the TDZ appears where people do not expect a declaration to matter at all.
Shadowing creates a fresh TDZ for the inner name
let roomNumber = 101;
{
console.log(roomNumber); // ReferenceError — NOT 101
let roomNumber = 202; // this inner let shadows the outer one
} // …and its TDZ covers the whole blockThe outer roomNumber is fully initialised — 101 is right there. But the block declares its own roomNumber (Lesson 7's shadowing), so inside the block every reference to that name resolves to the inner binding, which is locked from the block's opening brace until line 5. The lookup never climbs to the outer 101, because the nearest match wins — and the nearest match is in its dead zone. Reading line 4, you would swear it prints 101. It throws.
Default parameters are evaluated left to right, in their own scope
function book(nights = stay, stay = 1) {
return nights * stay;
}
book(); // ReferenceError: Cannot access 'stay' before initialization
book(2); // 2 — nights was given, so `stay` is never read earlyParameters with defaults behave like a sequence of let declarations, evaluated in order. When book() is called with no arguments, the engine evaluates nights = stay first — and stay, one position to the right, is still in its TDZ. Give nights a value and its default expression is never evaluated, so stay is never read early and nothing throws. A default may refer to any parameter to its left, never to its right.
Redeclaration: var Allows It, let And const Don't
var guestCount = 10;
var guestCount = 20; // totally fine — var allows redeclaring
let roomNumber = 101;
let roomNumber = 102; // SyntaxError: Identifier 'roomNumber' has already been declared
let discountRate = 5;
var discountRate = 10; // also SyntaxError — mixing var with an existing let in the same scope| Combination in the same scope | Result |
|---|---|
var declared twice | Allowed |
let declared twice | SyntaxError |
const declared twice | SyntaxError |
var then let/const with the same name | SyntaxError |
The Parse-Time Twist: Why Some Errors Crash Everything
Before running a single line, the JS engine reads and checks your entire file first. A duplicate let declaration is caught right here, during this check — which means the error exists before execution has even started.
console.log("This never prints — not even this line!");
let x = 1;
let x = 2; // SyntaxError: Identifier 'x' has already been declaredCompare that with a TDZ violation, which is a genuine runtime error:
console.log("This DOES print completely fine");
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 1;This is the parse phase. Every line is read and checked; nothing is executed yet. Both files are still just text.
1console.log("This never prints");2let x = 1;3let x = 2; // SyntaxError
1console.log("This DOES print");2console.log(y); // ReferenceError3let y = 1;
Same file structure, wildly different outcomes. A SyntaxError is found while the engine is still reading your file — nothing runs, not even correct code positioned before the mistake. A ReferenceError only happens once execution actually reaches that specific line — everything before it runs completely normally.
The Five Error Types — Full Taxonomy
| Error | Cause | Example |
|---|---|---|
ReferenceError: x is not defined | Name never declared anywhere in scope | console.log(neverDeclared) |
ReferenceError: Cannot access 'x' before initialization | Accessed during the TDZ | console.log(a); let a = 1; |
SyntaxError: Identifier has already been declared | Redeclaring a let/const | let x = 1; let x = 2; |
SyntaxError: Missing initializer in const declaration | An uninitialized const | const x; |
TypeError: Assignment to constant variable | Reassigning a const | const x = 1; x = 2; |
let x = 1; let x = 2;const x;console.log(neverDeclared)console.log(a); let a = 1;const x = 1; x = 2;Five genuinely different mistakes, five genuinely different error names. Interviewers use this table constantly — being able to name the exact error for a given snippet, without running it, is a strong, quick signal that you actually understand what's happening rather than just recognizing keywords.
The typeof Exception: TDZ Breaks The Safety Net
Lesson 6 taught you that typeof is always safe — it never throws, even on a name that was never declared. That rule has exactly one exception, and it's the TDZ:
console.log(typeof unicornVariable); // "undefined" — safe, never declared anywhere
console.log(typeof discountRate); // ReferenceError! — even typeof can't rescue a TDZ access
let discountRate = 10;let discountRate is hoisted: a slot exists, locked (TDZ) until line 4 runs. unicornVariable is declared nowhere — no slot at all.
1console.log(typeof unicornVariable); // "undefined"23console.log(typeof discountRate); // ReferenceError!4let discountRate = 10;
typeof only protects you against a name that doesn't exist at all. It offers zero protection against a name that exists but is still locked in the TDZ — that still throws, safety net or not. This single case is genuinely easy to get wrong, including for experienced developers who rely on the rule “typeof is always safe” without exception.
const: A Frozen Binding, Not A Frozen Value
const is let plus two rules: the declaration must carry an initialiser (otherwise a SyntaxError at parse time — there would never be a moment to give it a value), and the binding can never be reassigned (a TypeError at runtime). Both rules are about the binding — the arrow from the name to the value. Neither says anything about the value at the other end of the arrow.
const guest = { name: "Aditi", room: 101 };
guest.room = 102; // fine — the object changed, the binding did not
guest = { name: "Rahul" }; // TypeError: Assignment to constant variable.
const frozen = Object.freeze({ name: "Aditi", room: 101 });
frozen.room = 102; // silently ignored (throws in strict mode)
console.log(frozen.room); // 101- name"Aditi"
- room101 → 102 ✓
For primitives (numbers, strings, booleans) the distinction is invisible, because a primitive cannot be changed in place — the only way to “change” it is to re-point the binding, which const forbids. For objects and arrays the distinction is everything: guest.room = 102 reaches through the arrow and edits the object; the arrow itself is untouched. If you want the object frozen too, that is a separate, explicit request — Object.freeze — and it is shallow: nested objects inside remain mutable.
Minimizing Your Own TDZ Window — And Which Keyword To Use
The TDZ exists for every let/const from the top of its scope until its declaration line runs. You can't remove it, but you can make it as short as possible:
- Declare variables as close as possible to the top of the scope they belong to.
- Avoid deeply separating a variable's declaration from its first real use.
- Prefer
constby default, useletonly when reassignment is genuinely needed, and avoidvarentirely in new code — this alone sidesteps almost every mistake in this lesson.
const unless you will reassign; then let; never var. The reasoning is the matrix above: const gives you block scope, no accidental global property, no silent redeclaration, and a guarantee that the name always points at the same thing — every one of those is a class of bug removed. Reach for let only when the binding genuinely has to move (a loop counter, an accumulator). You will mostly meet var in older code (written before 2015, when let and const arrived) and in interview questions that check you understand why it was replaced.
Real Example — Traced Line By Line
function checkIn() {
console.log(typeof roomReady); // ReferenceError — TDZ, not "undefined"
let roomReady = true;
console.log(roomReady); // true
let roomReady = false; // SyntaxError, caught before checkIn() even runs
}
checkIn();The engine reads through checkIn's body ahead of time — before checkIn() is ever called.
1function checkIn() {2console.log(typeof roomReady);3let roomReady = true;4console.log(roomReady);56let roomReady = false; // duplicate7}8checkIn();
File is parsed first
The engine reads through
checkIn's body ahead of time and finds twolet roomReadydeclarations in the same scope — aSyntaxErroris raised immediately.Nothing inside checkIn ever runs
Not the first
console.log, not the assignment, not the secondconsole.log— the whole function body is invalid beforecheckIn()is ever called.Fixing it
Remove the duplicate declaration, and now the first line correctly throws a different error — a TDZ
ReferenceErrorfrom thetypeofpitfall — sinceroomReadyhasn't been declared yet at that point.
See It Live — Console & DevTools
Open the console
Any page → DevTools (F12) → Console tab.
Trigger a SyntaxError
consolelet a = 1; let a = 2; // SyntaxError — paste both lines together as one snippetTrigger a TDZ ReferenceError
consoleconsole.log(typeof b); // ReferenceError let b = 1;Compare with an undeclared name
consoleconsole.log(typeof totallyUnknown); // "undefined" — no error at allWatch the TDZ in the Scope panel
scope-panel.jsfunction checkIn() { debugger; // paused here, roomNumber is in the TDZ let roomNumber = 101; debugger; // paused here, it is 101 } checkIn();At the first pause, open Scope → Local: Chrome lists
roomNumberalready, with the value<value unavailable>— its way of showing a binding that exists but is locked. Resume to the second pause and the same entry reads101. That is the TDZ, rendered by the engine itself.Count the loop slots
consolefor (let i = 0; i < 3; i++) { debugger; }Each time it pauses, Scope → Block shows an
i— and stepping to the next iteration shows a new Block scope, not the same one with a bigger number. Swapletforvarand the Block scope disappears:inow sits in Local (or Global) for the whole loop.Confirm the difference side by side
consoleconsole.log("hello"); let a = 1; let a = 2;Paste this as one snippet. Only the SyntaxError appears and
hellonever prints. That is proof the error was caught before any line ran. Now remove the last line and paste again:helloprints.
Common Misconceptions
“All JavaScript errors behave the same way — they just stop at the line that's wrong.” Not true. A SyntaxError is caught while the engine is still reading your file, before anything runs — even correct code above the mistake never executes. A ReferenceError or TypeError only fires once execution actually reaches that line.
“typeof never throws, under any circumstances.” It doesn't throw for a name that was never declared — but it does throw for a name that exists and is still stuck in the TDZ.
“var and let behave the same way regarding redeclaration, just with different scoping.” They don't — var happily allows redeclaring the same name in the same scope; let/const throw a SyntaxError immediately if you try.
“The TDZ is the lines above the declaration.” It is the time before the declaration is evaluated. A function written above the let reads it fine when called after the let has run, and throws when called before — same lines, different moment.
“If the outer variable is initialised, an inner one with the same name is safe to read.” Shadowing gives the inner name its own binding with its own TDZ, and the lookup stops at the nearest match — so the initialised outer value is never even consulted.
“const makes the object immutable.” It makes the binding immutable. Properties of the object can still be changed; only re-pointing the name is forbidden. Freezing the value is a separate operation.
Interview Questions
Is let hoisted? Then why does reading it early throw instead of giving undefined?
Yes, all three keywords are hoisted — a slot is reserved in the memory phase. The difference is the slot's starting state: var is initialised to undefined immediately; let/const are left uninitialised (locked) until their declaration is evaluated. Reading a locked binding throws a ReferenceError. The locked stretch is the Temporal Dead Zone.
What is the scope of var versus let/const?
var is function-scoped: it belongs to the nearest enclosing function (or Global) and ignores all other braces. let/const are block-scoped: they belong to the nearest enclosing { } of any kind and cease to exist when it closes.
What does for (let i = 0; …) do differently from for (var i = 0; …)?
With var there is one i slot for the whole loop, which survives after it ends. With let the engine creates a fresh binding for every iteration — three iterations, three separate slots — and discards them when the loop ends, so i is not defined afterwards. The per-iteration binding is what makes closures inside the loop capture the right value.
Exactly where does the TDZ start and end?
It starts at the top of the enclosing scope (the opening brace, or the first line of the function/file), not at the line just above the declaration. It ends the moment the declaration is evaluated — for let x; with no initialiser, that sets x to undefined and unlocks it.
Why is it called the *Temporal* Dead Zone?
Because it is defined by time, not position. A function that reads a let variable can be written above the declaration and still work — as long as it is called after the declaration has been evaluated. The same function called before that moment throws. Scope is decided by where code is written (lexical); readiness is decided by when it runs (temporal).
Can shadowing cause a TDZ error even when the outer variable is initialised?
Yes. An inner let with the same name creates a separate binding with its own TDZ covering the whole inner block. Lookups inside the block resolve to the inner binding first (nearest match wins), so reading the name before the inner declaration throws — the initialised outer value is never reached.
Why does function f(a = b, b = 1) {} throw when called with no arguments?
Default parameters are evaluated left to right in their own scope, like a sequence of let declarations. Evaluating a = b reads b while it is still in its TDZ. A default may refer to parameters on its left, never on its right.
Does const make a value immutable?
No — it makes the binding immutable: the name can never be re-pointed, and it must be given a value on the declaration line. An object or array it points to can still be mutated through it. Object.freeze is the (shallow) way to freeze the value itself.
Can you redeclare a var in the same scope? Can you redeclare a let?
var can be redeclared freely in the same scope. let/const throw a SyntaxError if redeclared in the same scope.
Why does a SyntaxError sometimes prevent even correct code from running?
Because the engine parses the entire file before executing any of it — a SyntaxError is caught during that parse phase, so nothing in the file runs at all, not even code positioned before the mistake.
Name the five error types covered here, and what triggers each.
“is not defined” (never declared), “cannot access before initialization” (TDZ), duplicate declaration (redeclaring let/const), missing initializer (uninitialized const), and assignment to constant (reassigning const).
Does typeof ever throw an error?
Yes — one exception. It's safe on a name that was never declared at all, but it still throws if the name exists and is currently inside the TDZ.
How can you minimize the practical impact of the TDZ in your own code?
Declare variables as close to the top of their scope as possible, and default to const, using let only where reassignment is truly needed.
What's the difference between a SyntaxError and a runtime error like ReferenceError or TypeError?
A SyntaxError is found while the engine reads the file, before execution begins. ReferenceError and TypeError only happen once execution reaches the offending line.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Part of the study guide
Related series