docs/beyond/concepts/temporal-dead-zone.mdx
Why does this code throw an error?
console.log(name) // ReferenceError: Cannot access 'name' before initialization
let name = "Alice"
But this code works fine?
console.log(name) // undefined (no error!)
var name = "Alice"
The difference is the Temporal Dead Zone (TDZ). It's a behavior that makes let, const, and class declarations safer than var by catching bugs early.
The Temporal Dead Zone (TDZ) in JavaScript is the period between entering a scope and the line where a let, const, or class variable is initialized. During this zone, the variable exists but cannot be accessed—any attempt throws a ReferenceError. The TDZ prevents bugs by catching accidental use of uninitialized variables. As defined in the ECMAScript specification, let and const bindings are created when their containing environment record is instantiated but remain uninitialized until their declaration is evaluated.
{
// TDZ for 'x' starts here (beginning of block)
console.log(x) // ReferenceError: Cannot access 'x' before initialization
let x = 10 // TDZ for 'x' ends here
console.log(x) // 10 (works fine)
}
The TDZ applies to:
let declarationsconst declarationsclass declarationsThink of the TDZ like a restaurant reservation system.
When you make a reservation, your table is reserved from the moment you call. The table exists, it has your name on it, but you can't sit there yet. If you show up early and try to sit down, the host will stop you: "Sorry, your table isn't ready."
The table becomes available only when your reservation time arrives. Then you can sit, order, and enjoy your meal.
┌─────────────────────────────────────────────────────────────────────────┐
│ THE TEMPORAL DEAD ZONE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ { // You enter the restaurant (scope begins) │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ TEMPORAL DEAD ZONE FOR 'x' │ │
│ │ │ │
│ │ Table reserved, but NOT ready yet │ │
│ │ │ │
│ │ console.log(x); // "Table isn't ready!" │ │
│ │ // ReferenceError │ │
│ │ │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ let x = 10; // Reservation time! Table is ready. │
│ │
│ console.log(x); // "Here's your table!" → 10 │
│ │
│ } │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Variables in the TDZ are like reserved tables: they exist, JavaScript knows about them, but they're not ready for use yet.
Both var and let/const are hoisted, meaning JavaScript knows about them before code execution. The difference is in initialization:
| Aspect | var | let / const |
|---|---|---|
| Hoisted? | Yes | Yes |
| Initialized at hoisting? | Yes, to undefined | No (remains uninitialized) |
| Access before declaration? | Returns undefined | Throws ReferenceError |
| Has TDZ? | No | Yes |
┌─────────────────────────────────────────────────────────────────────────┐
│ var vs let/const HOISTING │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ VAR: Hoisted + Initialized LET/CONST: Hoisted Only │
│ ────────────────────────── ──────────────────────── │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ // JS does this: │ │ // JS does this: │ │
│ │ var x = undefined │ ← ready │ let y (uninitialized)│ ← TDZ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ console.log(x) // undefined console.log(y) // Error! │
│ │ │ │
│ ▼ ▼ │
│ var x = 10 // reassignment let y = 10 // initialization │
│ │ │ │
│ ▼ ▼ │
│ console.log(x) // 10 console.log(y) // 10 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Here's the behavior in code:
function varExample() {
console.log(x) // undefined (not an error!)
var x = 10
console.log(x) // 10
}
function letExample() {
console.log(y) // ReferenceError: Cannot access 'y' before initialization
let y = 10
console.log(y) // never reaches here
}
The word "temporal" means related to time. The TDZ is "temporal" because it depends on when code executes, not where it appears in the source. MDN documents this distinction explicitly: the zone is defined by the execution flow, not the lexical position of the code.
This is a subtle but important distinction. Look at this example:
{
// TDZ for 'x' starts here
const getX = () => x // This function references x
let x = 42 // TDZ ends here
console.log(getX()) // 42 - works!
}
Wait, the function getX is defined before x is initialized. Why doesn't it throw an error?
Because the TDZ is about execution time, not definition time:
getX is defined during the TDZ, but that's finegetX is called after x is initializedgetX() runs, x is already availableThe TDZ only matters when you actually try to access the variable. Defining a function that will access it later is perfectly safe.
{
const getX = () => x // OK: just defining, not accessing
getX() // ReferenceError! Calling during TDZ
let x = 42
getX() // 42 - now it works
}
```javascript
{
// TDZ starts
console.log(x) // ReferenceError
let x = 10 // TDZ ends
console.log(x) // 10
}
```
```javascript
{
// TDZ starts
console.log(PI) // ReferenceError
const PI = 3.14159 // TDZ ends
console.log(PI) // 3.14159
}
```
```javascript
const instance = new MyClass() // ReferenceError
class MyClass {
constructor() {
this.value = 42
}
}
const instance2 = new MyClass() // Works fine
```
This applies to class expressions too when assigned to `let` or `const`.
```javascript
// Works: b can reference a
function example(a = 1, b = a + 1) {
return a + b // 1 + 2 = 3
}
// Fails: a cannot reference b (TDZ!)
function broken(a = b, b = 2) {
return a + b // ReferenceError
}
```
```javascript
class Config {
static baseUrl = "https://api.example.com"
static apiUrl = Config.baseUrl + "/v1" // Works
}
class Example {
static first = Example.second // undefined (property doesn't exist yet)
static second = 10
}
// Example.first is undefined, Example.second is 10
```
However, the class itself is in TDZ before its declaration:
```javascript
const x = MyClass.value // ReferenceError: MyClass is in TDZ
class MyClass {
static value = 10
}
```
Here's a tricky edge case. The typeof operator is normally "safe" to use with undeclared variables:
console.log(typeof undeclaredVar) // "undefined" (no error)
But typeof throws a ReferenceError when used on a TDZ variable:
{
console.log(typeof x) // ReferenceError: Cannot access 'x' before initialization
let x = 10
}
This catches developers off guard because typeof is often used for "safe" variable checking. With let and const, that safety doesn't apply during the TDZ.
Destructuring follows the same left-to-right evaluation as default parameters:
// Works: b can use a's default
let { a = 1, b = a + 1 } = {}
console.log(a, b) // 1, 2
// Fails: a cannot use b (TDZ!)
let { a = b, b = 1 } = {} // ReferenceError
Self-referencing is also a TDZ error:
let { x = x } = {} // ReferenceError: Cannot access 'x' before initialization
The x on the right side of = refers to the x being declared, which is still in the TDZ.
The loop variable is in TDZ during header evaluation:
// This throws because 'n' is used in its own declaration
for (let n of n.values) { // ReferenceError
console.log(n)
}
A key let behavior in loops: each iteration gets a fresh binding:
const funcs = []
for (let i = 0; i < 3; i++) {
funcs.push(() => i)
}
console.log(funcs[0]()) // 0
console.log(funcs[1]()) // 1
console.log(funcs[2]()) // 2
With var, all closures share the same variable:
const funcs = []
for (var i = 0; i < 3; i++) {
funcs.push(() => i)
}
console.log(funcs[0]()) // 3
console.log(funcs[1]()) // 3
console.log(funcs[2]()) // 3
This fresh binding is why let in loops avoids the classic closure trap.
ES modules can import each other in a circle. When this happens, TDZ can cause runtime errors that are hard to debug.
// -- a.js (entry point) --
import { b } from "./b.js"
console.log("a.js: b =", b) // 1
export const a = 2
// -- b.js --
import { a } from "./a.js"
console.log("b.js: a =", a) // ReferenceError!
export const b = 1
┌─────────────────────────────────────────────────────────────────────────┐
│ ES MODULE CIRCULAR IMPORT TDZ │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ EXECUTION ORDER │
│ ─────────────── │
│ │
│ 1. Start a.js │
│ │ │
│ ▼ │
│ 2. import { b } from "./b.js" ──────┐ │
│ [a.js pauses, a not yet exported] │ │
│ ▼ │
│ 3. Start b.js │
│ │ │
│ ▼ │
│ 4. import { a } from "./a.js" │
│ [a exists but in TDZ!] │
│ │ │
│ ▼ │
│ 5. console.log(a) │
│ │ │
│ ▼ │
│ ReferenceError! │
│ a is in TDZ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Solution 1: Lazy Access
Don't access the imported value at the top level. Access it inside a function that runs later:
// -- b.js (fixed) --
import { a } from "./a.js"
// Don't access 'a' immediately
export const b = 1
// Access 'a' later, when it's definitely initialized
export function getA() {
return a
}
Solution 2: Restructure Modules
Break the circular dependency by extracting shared code:
// -- shared.js --
export const a = 2
export const b = 1
// -- a.js --
import { a, b } from "./shared.js"
// -- b.js --
import { a, b } from "./shared.js"
Solution 3: Dynamic Import
Use import() to defer loading:
// -- b.js --
export const b = 1
export async function getA() {
const { a } = await import("./a.js")
return a
}
The most common TDZ trap involves variable shadowing:
// ❌ WRONG - Shadowing creates a TDZ trap
const x = 10
function example() {
console.log(x) // ReferenceError! Inner x is in TDZ
let x = 20 // This shadows the outer x
return x
}
example() // ReferenceError!
The inner let x shadows the outer const x. When you try to read x before the inner declaration, JavaScript sees you're trying to access the inner x (which is in TDZ), not the outer one.
┌─────────────────────────────────────────────────────────────────────────┐
│ TDZ SHADOWING TRAP │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ WRONG RIGHT │
│ ───── ───── │
│ │
│ const x = 10 const x = 10 │
│ │
│ function broken() { function fixed() { │
│ console.log(x) // TDZ Error! const outer = x // 10 │
│ let x = 20 let y = 20 // different name│
│ return x return outer + y │
│ } } │
│ │
│ // The inner x shadows the outer // No shadowing, no TDZ trap │
│ // but inner x is in TDZ at log │
│ │
└─────────────────────────────────────────────────────────────────────────┘
// ✓ CORRECT - Capture outer value before shadowing
const x = 10
function fixed() {
const outerX = x // Capture outer x first
let y = 20 // Use different name, no shadowing
return outerX + y // Use both: 10 + 20 = 30
}
TDZ might seem like an annoyance, but it exists for good reasons. According to the TC39 committee notes on ES6 development, the TDZ was introduced specifically to make let and const safer alternatives to var by catching common programming mistakes at the point of error rather than letting them propagate silently:
With var, using a variable before initialization silently gives you undefined:
function calculateTotal() {
var total = price * quantity // undefined * undefined = NaN
var price = 10
var quantity = 5
return total
}
console.log(calculateTotal()) // NaN - silent bug!
With let/const, the bug is caught immediately:
function calculateTotal() {
let total = price * quantity // ReferenceError!
let price = 10
let quantity = 5
return total
}
If const didn't have a TDZ, you could observe it in an "undefined" state:
// Hypothetically, without TDZ:
console.log(PI) // undefined (?)
const PI = 3.14159
That contradicts the purpose of const. A constant should always have its declared value. The TDZ ensures you can never see a const before it has its assigned value.
In languages without TDZ-like behavior, you can accidentally use variables in confusing ways:
function setup() {
initialize(config) // Uses config before it's defined
const config = loadConfig()
}
The TDZ forces you to organize code logically: definitions before usage.
When you move code around, TDZ helps catch mistakes:
// Original
let data = fetchData()
processData(data)
// After refactoring (accidentally moved)
processData(data) // ReferenceError - you'll notice immediately!
let data = fetchData()
TDZ = the time between scope entry and variable initialization. During this period, the variable exists but throws ReferenceError when accessed.
let, const, and class have TDZ. var does not. The var keyword initializes to undefined immediately, so there's no dead zone.
"Temporal" means time, not position. A function can reference a TDZ variable if it's called after initialization, even if it's defined before.
typeof is not safe in TDZ. Unlike undeclared variables, typeof on a TDZ variable throws ReferenceError.
Default parameters have TDZ rules. Later parameters can reference earlier ones, but not vice versa.
Circular ES module imports can trigger TDZ. If module A imports from B while B imports from A, one will see uninitialized exports.
Shadowing + TDZ = common trap. When you declare a variable that shadows an outer one, the outer variable becomes inaccessible from the TDZ start.
TDZ catches bugs early. It prevents silent undefined values from causing hard-to-debug issues.
TDZ makes const meaningful. Constants never have a temporary "undefined" state.
Structure code with declarations first. This is the simplest way to avoid TDZ issues entirely.
</Info>The Temporal Dead Zone (TDZ) is the period between entering a scope and the point where a variable declared with `let`, `const`, or `class` is initialized. During this period, the variable exists (it's been hoisted) but cannot be accessed. Any attempt to read or write to it throws a `ReferenceError`.
```javascript
{
// TDZ starts here for 'x'
console.log(x) // ReferenceError
let x = 10 // TDZ ends here
console.log(x) // 10
}
```
Both `var` and `let`/`const` are hoisted, but they differ in initialization:
- **`var`**: Hoisted AND initialized to `undefined`. No TDZ.
- **`let`/`const`**: Hoisted but NOT initialized. TDZ until declaration.
```javascript
console.log(x) // undefined (var is initialized)
var x = 10
console.log(y) // ReferenceError (let is in TDZ)
let y = 10
```
For **undeclared** variables, `typeof` returns `"undefined"` as a safety feature. For **TDZ** variables, JavaScript knows the variable exists (it's been hoisted), so it enforces the TDZ restriction.
```javascript
console.log(typeof undeclared) // "undefined" (safe)
{
console.log(typeof x) // ReferenceError (TDZ enforced)
let x = 10
}
```
The difference is: undeclared means "doesn't exist," while TDZ means "exists but not ready."
**Yes, but only if the function is called after the variable is initialized.** Defining the function during TDZ is fine. Calling it during TDZ throws an error.
```javascript
{
const getX = () => x // OK: defining, not accessing
// getX() // Would throw: x is in TDZ
let x = 42 // TDZ ends
console.log(getX()) // 42: called after TDZ
}
```
This is why it's called "temporal" (time-based), not "positional" (code-position-based).
It throws a `ReferenceError`. The `x` on the right side refers to the `x` being declared, which is still in TDZ at the time of evaluation.
```javascript
let x = x // ReferenceError: Cannot access 'x' before initialization
```
This also applies in destructuring:
```javascript
let { x = x } = {} // ReferenceError
```
TDZ exists for several reasons:
1. **Catch bugs early**: Using uninitialized variables throws immediately instead of silently returning `undefined`
2. **Make `const` meaningful**: Constants should always have their declared value, never a temporary `undefined`
3. **Enforce logical code structure**: Encourages declaring variables before using them
4. **Safer refactoring**: Moving code around reveals dependency issues immediately
```javascript
// Without TDZ (var), this bug is silent:
var total = price * quantity // NaN
var price = 10
var quantity = 5
// With TDZ (let), the bug is caught:
let total = price * quantity // ReferenceError!
let price = 10
let quantity = 5
```