DocsHub
ES6+

Let and Const

A deeper look at let and const — block scoping, the temporal dead zone, and when to use each.

Let and Const

We covered let and const in the Basics section — what they are, how to use them, and why var was replaced. This topic goes deeper — the exact rules of block scoping, the temporal dead zone, and some edge cases worth knowing.

If you need a refresher on the basics, revisit the Variables topic in the Basics section.


Block Scoping — The Core Rule

let and const are block scoped. A block is any code inside curly braces {}. The variable only exists inside the block where it was declared — and any nested blocks inside it.

{
  let message = "Hello";
  const PI = 3.14;
  console.log(message); // Hello
  console.log(PI);      // 3.14
}

console.log(message); // ❌ ReferenceError
console.log(PI);      // ❌ ReferenceError

This applies everywhere curly braces appear — if blocks, loops, functions, or just a standalone block.

Block scoping in if statements

const score = 85;

if (score >= 80) {
  const grade = "A";
  let feedback = "Excellent work!";
  console.log(grade);    // A
  console.log(feedback); // Excellent work!
}

console.log(grade);    // ❌ ReferenceError — only existed in the if block
console.log(feedback); // ❌ ReferenceError — only existed in the if block

Block scoping in loops

for (let i = 0; i < 3; i++) {
  const squared = i * i;
  console.log(squared); // 0, 1, 4
}

console.log(i);       // ❌ ReferenceError
console.log(squared); // ❌ ReferenceError

Every iteration of a for loop with let gets its own fresh binding — this is what makes closures inside loops work correctly.

The classic closure in a loop bug

// ❌ var — all callbacks share the same i
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 3
// 3
// 3
// By the time setTimeout fires, the loop is done and i is 3

// ✅ let — each iteration gets its own i
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// 0
// 1
// 2

let creates a new binding for i on every iteration. Each setTimeout callback captures its own i. This is one of the most important practical differences between var and let.


The Temporal Dead Zone — In Depth

We introduced the TDZ in the Hoisting topic. Here is the full picture.

When JavaScript scans a block before running it, it registers all let and const declarations. But they are not initialized — they sit in the Temporal Dead Zone until the line where they are declared is reached.

{
  // TDZ for name starts here
  console.log(name); // ❌ ReferenceError — in TDZ

  let name = "Ali"; // TDZ ends here — name is now initialized

  console.log(name); // ✅ Ali
}

The TDZ is not about the variable not existing — it exists in memory, it is just deliberately inaccessible. This is different from var which is accessible but undefined.

console.log(typeof undeclaredVar); // "undefined" — no error for truly undeclared vars
console.log(typeof letVar);        // ❌ ReferenceError — in TDZ
let letVar = "value";

Even typeof — which normally never throws — throws a ReferenceError for variables in the TDZ.

TDZ in function parameters

// ❌ Using a parameter default that references a later parameter
function wrong(a = b, b = 1) {
  return a + b;
}
wrong(); // ReferenceError — b is in TDZ when a's default is evaluated

// ✅ Reference earlier parameters only
function correct(a = 1, b = a) {
  return a + b;
}
correct(); // 2

const — What It Really Means

const prevents reassignment — not mutation. The variable cannot be pointed to a different value, but if the value is an object or array, its contents can still change.

// ✅ Primitives — truly constant
const PI = 3.14159;
const MAX_SIZE = 100;
const APP_NAME = "DocsHub";

PI = 3; // ❌ TypeError: Assignment to constant variable
// Objects — reference is constant, contents are not
const user = { name: "Ali", age: 22 };

user.age = 23;           // ✅ mutating contents — allowed
user.city = "Lahore";    // ✅ adding a property — allowed
user = { name: "Sara" }; // ❌ reassigning the variable — not allowed
// Arrays — same rule
const scores = [88, 92, 76];

scores.push(95);  // ✅ mutating contents — allowed
scores[0] = 100;  // ✅ changing an item — allowed
scores = [];      // ❌ reassigning — not allowed

If you want a truly immutable object, use Object.freeze():

const config = Object.freeze({
  apiUrl: "https://api.example.com",
  timeout: 5000
});

config.timeout = 9000; // silently fails — or throws in strict mode
console.log(config.timeout); // 5000 — unchanged

Object.freeze() is shallow — nested objects are not frozen. For deep immutability you need a library or recursive freezing.


let vs const — The Decision Rule

A simple rule that works in almost every situation:

Use const by default.
Switch to let only when you know the value needs to change.
// const — value never reassigned
const users = [];          // array contents change but variable stays the same
const config = {};         // object properties change
const MAX_RETRIES = 3;     // true constant
const apiUrl = "/api/v1";  // string constant

// let — value gets reassigned
let count = 0;
count++;                   // reassignment

let isLoading = false;
isLoading = true;          // reassignment

let currentUser = null;
currentUser = fetchedUser; // reassignment

Notice that users and config are const even though their contents change — they are never reassigned. Only use let when the variable itself gets a new value.


Multiple Declarations and Redeclaration

// var — can be declared twice in the same scope (bug-prone)
var name = "Ali";
var name = "Sara"; // no error — silently overwrites
console.log(name); // Sara

// let — cannot be declared twice in the same scope
let name = "Ali";
let name = "Sara"; // ❌ SyntaxError: Identifier 'name' has already been declared

// const — same, cannot redeclare
const PI = 3.14;
const PI = 3;     // ❌ SyntaxError

This is intentional — redeclaring the same variable in the same scope is almost always a bug. let and const catch it immediately.


Destructuring With let and const

Destructuring respects the same rules:

// const destructuring — neither variable can be reassigned
const { name, age } = user;
const [first, second] = scores;

// let destructuring — both variables can be reassigned
let { name, age } = user;
name = "Sara"; // ✅ allowed

// Swapping with let — only works with let, not const
let a = 1;
let b = 2;
[a, b] = [b, a]; // ✅ reassignment

Summary

  • let and const are block scoped — they only exist within the {} block where they are declared
  • Each for loop iteration with let gets its own binding — fixes the classic closure bug with var
  • The Temporal Dead Zonelet and const variables exist but are inaccessible before their declaration line
  • Even typeof throws a ReferenceError for TDZ variables
  • const prevents reassignment — not mutation — objects and arrays declared with const can still be modified
  • Use Object.freeze() for true immutability
  • Default rule — use const everywhere, switch to let only when you need to reassign
  • let and const cannot be redeclared in the same scope — var can, silently

On this page