Chapter 4.2☕ 18 min read

let & const: TDZ, Block Scope & Redeclaration Rules

let bhi hoist hota hai — par khaali box mein. Chhuao toh ReferenceError.

0101 — let and const ARE Hoisted — Just Not Initialized

A common misconception: "let and const are not hoisted." This is wrong — they ARE hoisted. Their binding is created during the Creation Phase, just like var.

The critical difference: instead of being initialized to undefined (like var), they are marked "uninitialized". The binding EXISTS but is in the Temporal Dead Zone (TDZ) — accessing it throws ReferenceError.

TDZ ends when execution reaches the declaration line — the variable is initialized there. After that, it is fully accessible.

Proof that let IS hoisted: if let were not hoisted, an outer variable of the same name would be accessible inside the block. It is not — because the inner let shadows it from the very top of the block.

// Proof that let IS hoisted:
let outer = 'outer';

function test() {
  // If let were NOT hoisted, this would print 'outer'
  // But it throws ReferenceError — inner 'outer' IS hoisted
  console.log(outer); // ReferenceError!
  let outer = 'inner'; // ← this causes the hoisting above
}
// test(); // Uncomment to see the error

// The TDZ window:
{
  // ← TDZ for x starts here (binding created but uninitialized)
  // console.log(x); // ReferenceError — in TDZ
  let x = 10;        // ← TDZ ends here — x initialized to 10
  console.log(x);    // 10 ✅ — accessible after initialization
}
// x is not accessible here — block scope ended
V8's internal representation for TDZ: when let/const bindings are created in the environment record during parsing, they are marked with a special sentinel value called 'the hole' — not undefined, not null, but a C++ internal value meaning "not yet initialized". Any access that reads 'the hole' triggers the TDZ ReferenceError. This is why typeof on a TDZ let also throws — unlike typeof on undeclared variables.
0202 — Block Scope: How let and const Create New Environments

Every {} block creates a new Lexical Environment for let and const. This is fundamentally different from var, which is function-scoped and leaks out of blocks.

Key block scope behaviors:

  • For loops: each iteration creates a NEW block environment — let in a for loop gets a fresh binding per iteration
  • If blocks: let/const inside are invisible outside the if
  • Switch blocks: ONE shared environment for the whole switch (unless each case has its own {})
  • Nested blocks: inner block can shadow outer let/const
// Each block creates new environment for let/const
{
  let a = 1;
  {
    let a = 2; // new block — shadows outer a
    console.log(a); // 2
  }
  console.log(a); // 1 — outer a unaffected
}
// console.log(a); // ReferenceError — a not in scope

// for loop with let — fresh binding per iteration
const fns = [];
for (let i = 0; i < 3; i++) {
  fns.push(() => i); // each gets its OWN i
}
console.log(fns[0]()); // 0 ✅
console.log(fns[1]()); // 1 ✅
console.log(fns[2]()); // 2 ✅

// if block — scoped inside
if (true) {
  let secret = 'visible only here';
  console.log(secret); // 'visible only here' ✅
}
// console.log(secret); // ReferenceError ✅

// switch — shared block scope (gotcha!)
switch (1) {
  case 1:
    let x = 'one'; // in the SWITCH block scope
    break;
  case 2:
    let x = 'two'; // SyntaxError — already declared!
}
📋 Switch gotcha: The switch statement shares ONE block scope for all cases. Two let declarations with the same name in different cases causes SyntaxError. Fix: wrap each case body in its own {}: case 1: { let x = 'one'; break; } case 2: { let x = 'two'; break; } — now each case has its own block scope.
0303 — const: What It Actually Prevents (and What It Doesn't)

const prevents REBINDING of the variable — you cannot reassign the variable to point to something else. But const does NOT prevent MUTATION of the value it points to.

const + primitive: completely immutable (primitives cannot be mutated anyway — strings, numbers, booleans are value types).

const + object/array: the binding is fixed (the variable always points to the same object), but the object's contents can change — properties can be added, removed, or modified.

// const prevents rebinding
const x = 10;
// x = 20; // TypeError: Assignment to constant variable

// But const with objects — mutation is allowed!
const user = { name: 'Sai', age: 26 };
user.name = 'Rahul';  // ✅ mutation works
user.city = 'Hyd';   // ✅ adding property works
// user = { name: 'New' }; // TypeError — rebinding not allowed!
console.log(user); // { name: 'Rahul', age: 26, city: 'Hyd' }

// const with array — same pattern
const scores = [95, 87, 72];
scores.push(100);    // ✅ mutation allowed
scores[0] = 50;      // ✅ mutation allowed
// scores = [1,2,3]; // TypeError — rebinding not allowed

// Object.freeze() — prevents mutation (shallow only!)
const config = Object.freeze({ theme: 'dark', lang: 'en' });
config.theme = 'light'; // silently fails in sloppy mode
config.newProp = 'x';   // silently fails
console.log(config);    // still { theme: 'dark', lang: 'en' }

// But freeze is SHALLOW — nested objects not frozen!
const nested = Object.freeze({ settings: { dark: true } });
nested.settings.dark = false; // ✅ mutation works — nested not frozen!
console.log(nested.settings.dark); // false — changed!

// Deep freeze pattern:
function deepFreeze(obj) {
  Object.getOwnPropertyNames(obj).forEach(name => {
    const value = obj[name];
    if (typeof value === 'object' && value !== null) {
      deepFreeze(value); // recurse
    }
  });
  return Object.freeze(obj);
}
V8 implements const by adding a "read-only" flag to the environment record binding. The binding's slot is marked unwritable — any assignment attempt throws TypeError. This is purely a language-level constraint on the BINDING, not on the heap object it points to. The heap object's properties remain fully mutable.
0404 — TDZ in Real Scenarios: Where It Bites You

TDZ doesn't just exist in textbook examples — it bites you in real code patterns. Here are the most common scenarios:

1. typeof on a TDZ variable throws: Unlike typeof undeclaredVar (which safely returns "undefined"), typeof on a let/const in TDZ throws ReferenceError. The binding EXISTS — it's just uninitialized.

2. Default parameter TDZ: Default parameter values are evaluated in their own scope. A later parameter cannot be referenced in an earlier parameter's default value.

3. TDZ shadowing trap: When an inner let/const shadows an outer variable, the outer variable becomes inaccessible from the block start — not just from the declaration line.

4. Class field order: Class fields are initialized in declaration order. An earlier field referencing a later field gets undefined (the later field hasn't been initialized yet).

// typeof on TDZ let throws — unlike undeclared!
// console.log(typeof tdzVar); // ReferenceError — let is in TDZ!
let tdzVar = 'initialized';
console.log(typeof tdzVar);   // 'string' — safe after declaration

// Default parameter TDZ trap
function greet(name, greeting = name.toUpperCase()) {
  console.log(greeting);
}
greet('Sai'); // 'SAI' — works

// But default param cannot reference later params:
// function broken(a = b, b = 1) {} // ReferenceError!

// TDZ shadowing trap
let outer = 'outer value';
{
  // console.log(outer); // ReferenceError!
  // Inner let shadows outer from block START — TDZ applies
  let outer = 'inner value'; // TDZ ends here
  console.log(outer); // 'inner value'
}
console.log(outer); // 'outer value' — outer unchanged

// Class TDZ — fields depend on declaration order
class MyClass {
  static instance = null;
  static create() { return new MyClass(); } // fine — called later
}
0505 — let vs const vs var: The Decision Framework

The modern decision framework for variable declarations is simple:

Default: use const. It signals "this binding shouldn't change" and prevents accidental reassignment bugs. Most variables in well-written code don't need reassignment.

Use let when you need to reassign: loop counters, accumulating values across iterations, state machines, or reassignment inside try/catch blocks.

Avoid var in new code. The only valid uses are polyfills for ancient environments and understanding old codebases.

// Decision framework in practice:

// ✅ const — default choice
const MAX_RETRIES = 3;
const API_BASE = 'https://api.devinhyderabad.dev';
const user = fetchUser(); // binding won't change

// ✅ let — when reassignment is needed
let count = 0;
for (let i = 0; i < 10; i++) {
  count += i; // count reassigned each iteration
}
let response = null;
try {
  response = await fetchData(); // reassigned inside try
} catch (e) {
  response = { error: e.message };
}

// ❌ var — avoid in new code
// var result = compute(); // no reason to use var here

// ESLint prefer-const enforces this:
// let x = 5; // ← ESLint warns: use const instead

// Module constants — UPPER_CASE by convention
const DB_HOST = process?.env?.DB_HOST ?? 'localhost';
const DB_PORT = process?.env?.DB_PORT ?? 5432;
📋 The modern rule: const by default, let when you must reassign, var never. This is enforced by ESLint's prefer-const rule in virtually every production codebase. If you write let but never reassign, ESLint tells you to change it to const. Reading code with const signals "this binding is stable" — reduces cognitive load for everyone.

Lo kar liya — Key Points:

  • ✅ let and const ARE hoisted — their binding is created in Creation Phase but marked "uninitialized" (TDZ)
  • ✅ Temporal Dead Zone: the period from block start to the declaration line — accessing throws ReferenceError
  • typeof on a TDZ let/const throws ReferenceError — unlike typeof on undeclared variables (which returns "undefined")
  • ✅ let/const are block-scoped — each {} creates a new lexical environment for them
  • ✅ for...let creates a fresh binding per iteration — each closure captures its own independent variable
  • ✅ const prevents rebinding (reassignment) but NOT mutation — const object's properties can still change
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase