JAVASCRIPT / VARIABLES: VAR, LET, AND CONST
The temporal dead zone and using names too early
Diagnose and fix 'Cannot access X before initialization' by reasoning about when a let or const binding becomes usable.
What you will learn
- Trace a ReferenceError back to the exact let or const line that ends the TDZ
- Predict when typeof throws instead of returning the string undefined
- Explain why a function written above a let can still read it when called later
- Spot shadowing where a block's own let hides an outer name of the same spelling
Understanding The temporal dead zone and using names too early
A let or const declaration creates its binding as soon as control enters the enclosing block, but the engine marks that binding uninitialized until the declaration statement itself runs. Any read or write of the name during that gap throws a ReferenceError, and the gap is what the specification calls the temporal dead zone. The name is genuinely in scope for the whole block, which is why the error complains about initialization rather than about an unknown identifier.
The zone is temporal, not positional: it ends at the moment the declaration executes, not at a line number. A function written above the declaration can read the variable without trouble as long as the call happens later, because the closure keeps a reference to the binding and its state is checked at call time. The same rule explains why typeof is no escape hatch: the initialization check runs before typeof ever gets a value, so typeof on a name inside its TDZ throws, while typeof on a name that was never declared anywhere still evaluates to the string undefined.
The design exists because const must never be observed without its value, and because a variable that silently reads as undefined before its assignment turns misordered code into wrong answers instead of errors. Throwing on the early read puts the failure on the exact statement that is wrong. The practical cost is that declaring a name with let anywhere in a block makes an outer variable of that name unreachable for the entire block, so the fix for a surprising ReferenceError is sometimes a rename rather than a reorder.
function demo() {
try {
console.log(count);
} catch (e) {
console.log('before let ->', e.name);
}
let count = 1;
console.log('after let ->', count);
}
demo();
const read = () => label;
let label = 'ready';
console.log('called later ->', read());A let or const binding is created when its block is entered but stays unusable until its declaration statement actually executes, so failure depends on timing rather than line order.
Worked examples
typeof does not protect you
Shows that typeof is safe for a name that was never declared but throws for a name sitting in its TDZ.
console.log('undeclared:', typeof neverDeclared);
try {
console.log(typeof soon);
} catch (e) {
console.log('in TDZ:', e.name);
}
let soon = 'here';
console.log('after decl:', typeof soon);Example explained
Line 1typeof neverDeclared gives the string undefined because the name resolves to nothing, and typeof has a special path for unresolvable references.
Line 2typeof soon throws instead, because soon does resolve, to a binding in this scope that has not been initialized yet.
Line 3The caught error is a ReferenceError even though the variable is plainly in scope; the complaint is about state, not spelling.
Line 4Once let soon = 'here' has executed, the identical typeof expression reports string.
A block's own let hides the outer one
Demonstrates that an inner declaration puts the name in a TDZ from the block's first line, so the outer value cannot be read.
let mode = 'outer';
{
try {
console.log(mode);
} catch (e) {
console.log('inner read:', e.name);
}
let mode = 'inner';
console.log('after inner decl:', mode);
}
console.log('outer still:', mode);Example explained
Line 1The inner block declares its own mode, so inside that block the name refers to the inner binding from the opening brace onward.
Line 2console.log(mode) therefore reads the inner binding during its TDZ and throws rather than falling back to the outer variable.
Line 3After let mode = 'inner' runs, the same expression prints inner.
Line 4The outer binding was never modified, which is why it still prints outer once the block ends.
Parameter defaults have their own TDZ
Shows that parameters initialize left to right, so a default that refers to a later parameter throws only when it is actually evaluated.
function ok(a, b = a * 2) {
return [a, b];
}
function broken(a = b, b = 2) {
return [a, b];
}
console.log('ok:', ok(3).join(','));
try {
console.log(broken());
} catch (e) {
console.log('broken:', e.name);
}
console.log('broken with arg:', broken(9).join(','));Example explained
Line 1Parameters are initialized left to right in their own scope, so b = a * 2 may use a, which already has its value.
Line 2broken() evaluates a = b while b is still uninitialized, so that read throws a ReferenceError before the body runs.
Line 3broken(9) supplies a explicitly, so its default is never evaluated and no TDZ is entered, giving 9,2 from the very same function.
Important notes
The TDZ ends when the declaration statement runs even without an initializer: after let x; the variable reads as undefined, so a missing assignment is not what causes the error.
The error type is always ReferenceError, but the wording is engine specific (V8 says Cannot access 'x' before initialization, Firefox says it cannot access a lexical declaration before initialization); class declarations sit in the same TDZ.
Common mistakes
Wrapping a read in a typeof check to make it safe, when the name is declared with let later in the same scope; the guard itself throws and stops the script instead of returning undefined.
Expecting let to read as undefined before its declaration the way var does, then hunting for a missing value when the real event was a thrown ReferenceError on that line.
Reading a variable near the top of a block that declares the same name lower down; the read hits the inner binding's TDZ and throws, and the code starts working as soon as the inner variable is renamed, which makes the cause look like magic.
Try it yourself
Change, predict, then run
In a browser console, write let stage = 'outer'; then a block that logs stage inside a try/catch printing e.name, declares let stage = 'inner';, and logs stage again. Rename the inner variable to innerStage and watch the first log switch from ReferenceError to outer.
Open the JavaScript workspaceCheck your understanding
A top-level arrow function that returns total is written above let total = 5;. Calling that function before the let line throws, and calling the same function after it returns 5. Which explanation fits?
- The total binding is created when the script starts but stays marked uninitialized until its declaration statement runs, and the function checks it at call time.
- The arrow function captures a copy of total when it is created, so the result depends on when the function was defined.
- let declarations are hoisted together with their initializers, so the early call sees undefined rather than throwing.
- Arrow functions are exempt from the temporal dead zone, so the throw must come from something else.
Show answer
The closure holds the binding rather than a copied value, so one function object can throw at one moment and succeed later once the declaration has executed. Option three is tempting because var really does start out as undefined, but with let only the binding is created early; the initializer stays where it is written, and a read before it runs is a ReferenceError, never undefined.