ESC

Type to search the knowledge base.

Temporal Dead Zone

Why let and const throw if you touch them early — binding creation vs initialization, TDZ edges with defaults, typeof, and closures.

intermediate6 min read
  • javascript
  • tdz
  • let
  • const
  • scope

The Temporal Dead Zone (TDZ) is the stretch of time from when a scope’s let/const/class binding is created until it is initialized by evaluating its declaration. Access the binding in that window and you get ReferenceError — not undefined.

That is not “let isn’t hoisted.” The binding is registered at the start of the block. It is intentionally unusable until the initializer runs. The “temporal” part is about execution time inside the block, not calendar time.

If you only contrast “var is hoisted, let isn’t,” you’ll mis-explain half the interview questions and misread stack traces that say “before initialization.”

The problem TDZ fixes

With var, reading early is silent:

function legacy() {
  console.log(config); // undefined — bug hides until something assumes a real value
  var config = { theme: 'dark' };
}

let/const force the bug into the open:

function modern() {
  console.log(config); // ReferenceError: Cannot access 'config' before initialization
  const config = { theme: 'dark' };
}

TDZ is a correctness feature: no accidental use of a half-declared binding.

Model: create → dead zone → initialize → live

{
  // Binding for `x` exists here (block starts) — still in TDZ
  // console.log(x); // ReferenceError
  let x = 1; // initialization ends TDZ for `x`
  console.log(x); // 1
}

Mental sequence for a block:

  1. Enter block → create let/const bindings, mark uninitialized.
  2. Run statements top to bottom.
  3. Hit let x = expr → evaluate expr, store value, mark initialized.
  4. Later reads/writes use the live binding.

const is the same for TDZ, then forbids reassignment after init. TDZ and immutability are separate rules.

Classic demos interviewers use

Shadowing an outer binding

const value = 'outer';

{
  // console.log(value); // ReferenceError — inner `value` is in TDZ, not outer
  const value = 'inner';
  console.log(value); // inner
}

The inner declaration shadows for the whole block, including lines above it. The outer value is not “visible until the inner line.” That surprises people who think of TDZ as only “the one line.”

typeof is not a free pass

console.log(typeof undeclared); // "undefined" — no binding

{
  // console.log(typeof x); // ReferenceError — binding exists, still TDZ
  let x = 1;
}

typeof only soft-fails for truly undeclared identifiers (in non-module / non-strict edge discussions). A TDZ binding is declared; typeof still throws.

Functions that close over TDZ bindings

{
  const fn = () => n;
  // fn(); // would throw if called before init
  let n = 5;
  console.log(fn()); // 5 — called after init; closure sees live binding
}

Creating a function that mentions n is fine during TDZ. Calling it before initialization is not. Closures capture bindings, not snapshots of “was it ready.”

See Closures.

Default parameters

Parameter defaults are evaluated left-to-right in a special environment. Later parameters can see earlier ones; earlier cannot see later ones:

function pair(a = b, b = 2) {
  return [a, b];
}
// pair(); // ReferenceError when evaluating default for `a`

function pairOk(a = 1, b = a + 1) {
  return [a, b];
}
pairOk(); // [1, 2]
pairOk(5); // [5, 6]

Treat parameter list order like a mini TDZ scope. Reorder or compute defaults in the body when dependencies get messy.

class and circular const

// const c = new Circle(1); // ReferenceError
class Circle {
  constructor(r) {
    this.r = r;
  }
}
const a = b; // ReferenceError if b is const below in same scope
const b = 1;

Mutual const references in the same scope need care — one binding’s initializer cannot read the other while still dead.

var has no TDZ (and that is the bug)

function f() {
  console.log(x); // undefined
  var x = 3;
}

There is no uninitialized state for var after instantiation. You trade safety for historical behavior. New code: const by default, let when reassignment is real.

Full declaration comparison: Hoisting in JavaScript, var vs let vs const.

Where TDZ shows up in product code

Temporal dependency between hooks setup and constants

function Panel() {
  // Bad pattern in plain JS modules too:
  // useSomething(DEFAULT); // if DEFAULT is const below in same block — TDZ
  const DEFAULT = { open: false };
  // ...
}

Declare constants above first use. Linters and humans both prefer it; TDZ enforces it for let/const.

Switch / case without blocks

switch (tag) {
  case 'a':
    let msg = 'A'; // TDZ / redeclare issues if another case also lets `msg`
    break;
  case 'b':
    // let msg = 'B'; // SyntaxError: already declared in switch block
    break;
}

switch shares one block. Wrap cases in { } when you need case-local let/const.

switch (tag) {
  case 'a': {
    let msg = 'A';
    break;
  }
  case 'b': {
    let msg = 'B';
    break;
  }
}

Redeclaration

let x = 1;
// let x = 2; // SyntaxError in same scope

TDZ is about initialization time. Redeclaration is a separate parse-time error. Both push you toward clearer scopes.

Footguns

  1. Saying “let is not hoisted” — wrong. Binding is created; access is gated.
  2. Assuming outer variables are visible above an inner let of the same name — shadowing is whole-block.
  3. Calling a closure early that closes over a const still in TDZ.
  4. Default parameter order creating silent design debt when someone reorders args.
  5. Mixing var and let for the same mental model — they are different languages’ worth of behavior.

Interview angle

Prompt: “What is the temporal dead zone?”

Strong answer: “From the start of a block until a let/const/class declaration is evaluated, the binding exists but is uninitialized. Reading or writing it throws ReferenceError. That’s different from var, which initializes to undefined at scope entry. Shadowing means an inner binding is in TDZ for the whole block, so even an outer same-named variable isn’t readable above the inner declaration.”

Live code prediction:

let x = 1;
{
  // console.log(x);
  let x = 2;
}

Explain why the commented line would throw (inner TDZ), not print 1.

Connect to hoisting without collapsing the two ideas.

Further reading

Related guides