Temporal Dead Zone & Block Scoping

Difficulty: Intermediate

The Temporal Dead Zone (TDZ) is the period between entering a scope where a let or const variable is declared and the actual declaration line being executed. During this zone, the variable exists (it has been hoisted) but is in an uninitialized state - any attempt to read, write, or even use typeof on it throws a ReferenceError. The TDZ exists to catch programmer errors: using a variable before it is declared is almost always a bug, and the TDZ ensures you get an immediate, clear error rather than a silent undefined value.

The TDZ is not a physical region in your code - it is a temporal concept tied to the execution timeline. The zone starts when control enters the scope (the block, function, or module) and ends when the declaration statement is executed. This means the TDZ depends on the order of execution, not the order of lines in the file. A let variable on line 10 is in the TDZ if execution reaches code that references it before line 10 is executed - even if that referencing code appears after line 10 in the source.

Block scoping with let and const solved one of JavaScript's most notorious problems: the var-in-loops closure bug. When you use var in a for loop, all iterations share the same variable because var is function-scoped. If you create closures inside the loop (like setTimeout callbacks), they all see the final value of the loop variable. With let, each iteration gets its own variable - the loop body behaves as if a new let is declared for each iteration, which is exactly the behavior most developers expect.

Switch statements have a surprising scoping behavior that trips up even experienced developers. The entire switch block (all cases combined) forms a single block scope. This means a let or const variable declared in one case is technically in scope in all other cases, which can cause TDZ errors or naming conflicts. The solution is to wrap each case body in its own block with curly braces, creating a separate scope for each case.

Before let and const, the IIFE (Immediately Invoked Function Expression) pattern was the primary way to create block-like scoping. You would wrap code in (function() { ... })() to create a new function scope, isolating variables from the outer scope. While IIFEs are largely obsoleted by block scoping, understanding them helps you read older code and appreciate why let and const were such important additions to the language.

Code examples

TDZ in action - when and why it throws

// TDZ example 1: simple reference before declaration
try {
  console.log(myVar);  // undefined - var is hoisted
} catch (e) {
  console.log('var error:', e.message);
}
var myVar = 'hello';

try {
  console.log(myLet);  // ReferenceError - in TDZ
} catch (e) {
  console.log('let error:', e.message);
}
let myLet = 'hello';

// TDZ example 2: typeof is NOT safe with let/const
try {
  console.log(typeof undeclaredVar);  // 'undefined' - safe
  console.log(typeof tdzVar);         // ReferenceError!
} catch (e) {
  console.log('typeof error:', e.message);
}
let tdzVar = 'value';

var myVar is hoisted and initialized to undefined, so accessing it early works. let myLet is hoisted but placed in the TDZ, so accessing it throws a ReferenceError. Notably, even typeof - which is normally safe for undeclared variables - throws a ReferenceError when used on a variable in the TDZ.

var vs let in loops - the classic closure problem

// Problem: var in a loop with closures
const varFns = [];
for (var i = 0; i < 3; i++) {
  varFns.push(function() { return i; });
}
console.log(varFns[0](), varFns[1](), varFns[2]()); // all 3!

// Solution 1: let in a loop (each iteration gets its own i)
const letFns = [];
for (let j = 0; j < 3; j++) {
  letFns.push(function() { return j; });
}
console.log(letFns[0](), letFns[1](), letFns[2]()); // 0, 1, 2

// Solution 2 (old way): IIFE to capture each value
const iifeFns = [];
for (var k = 0; k < 3; k++) {
  iifeFns.push((function(captured) {
    return function() { return captured; };
  })(k));
}
console.log(iifeFns[0](), iifeFns[1](), iifeFns[2]());

With var, all closures share the same variable i, which is 3 after the loop ends. With let, each iteration creates a new j binding - the closures capture different variables. The IIFE approach (pre-ES6) creates a new function scope per iteration, capturing the current value of k as a parameter.

TDZ in switch statements

// Problem: all cases share one block scope
function processStatus(status) {
  switch (status) {
    case 'success':
      // let msg is visible in ALL cases (TDZ in others)
      let msg = 'Operation succeeded';
      console.log(msg);
      break;
    case 'error':
      // Uncommenting this would throw: msg is in TDZ here
      // let msg = 'Failed'; // SyntaxError: already declared
      console.log('Error case');
      break;
  }
}

// Solution: wrap each case in its own block
function processStatusFixed(status) {
  switch (status) {
    case 'success': {
      let msg = 'Operation succeeded';
      console.log(msg);
      break;
    }
    case 'error': {
      let msg = 'Operation failed'; // separate scope - no conflict
      console.log(msg);
      break;
    }
  }
}

processStatus('success');
processStatusFixed('error');

Without block wrappers, the entire switch body is one scope - you cannot declare the same let variable in two different cases. Adding curly braces around each case body creates separate block scopes, allowing each case to have its own msg variable.

Block scoping patterns and IIFE comparison

// Block scope for temporary variables
{
  const temp = [1, 2, 3, 4, 5];
  const sum = temp.reduce((a, b) => a + b, 0);
  console.log('Sum inside block:', sum);
}
// console.log(temp); // ReferenceError - temp is block-scoped

// Same thing with IIFE (pre-ES6 pattern)
(function() {
  var temp = [1, 2, 3, 4, 5];
  var sum = temp.reduce(function(a, b) { return a + b; }, 0);
  console.log('Sum inside IIFE:', sum);
})();
// console.log(temp); // ReferenceError - temp is function-scoped

// const in for...of (new binding per iteration)
const items = ['a', 'b', 'c'];
const fns = [];
for (const item of items) {
  fns.push(() => item); // each closure gets its own const item
}
console.log(fns[0](), fns[1](), fns[2]());

Block scoping with {} and let/const achieves the same isolation that IIFEs provided for var. The block approach is cleaner and more readable. In for...of loops, const creates a new binding per iteration just like let does in regular for loops, so closures capture the correct value.

Key points

Concepts covered

temporal dead zone, block scoping, let in loops, const in loops, var vs let closure, switch statement scoping, IIFE pattern