Topics in this subject
JavaScript 5 min read Updated 5 Aug 2026

Variables

var, let, const, Hoisting, The Temporal Dead Zone (TDZ), Scope, Reassignment vs mutation, immutability

var, let, const#

var 🔴 let 🟢 const 🟢
Scope function block block
Hoisted yes, initialized to undefined yes, but in TDZ yes, but in TDZ
Redeclarable in scope yes no no
Reassignable yes yes no
Creates global property (top level) yes (window.x) no no

🟢 Default to const. Use let only when you must reassign. Never use var in new code.

Hoisting#

ELI12. Before running your code, JavaScript "reads ahead" and reserves a spot in memory for your declarations. For var, it also secretly writes undefined in that spot. For function declarations, it writes the whole function. So you can call a function before you wrote it in the file.

console.log(x);   // undefined (var is hoisted & initialized)
var x = 5;

greet();          // works — function declarations are fully hoisted
function greet() { console.log("hi"); }

console.log(y);   // ❌ ReferenceError — let is hoisted but in the TDZ
let y = 5;
sequenceDiagram
    participant E as Engine
    Note over E: Creation phase (hoisting)
    E->>E: var x → memory, = undefined
    E->>E: function greet → memory, = full function
    E->>E: let y → memory, uninitialized (TDZ)
    Note over E: Execution phase
    E->>E: console.log(x) → undefined
    E->>E: greet() → runs
    E->>E: access y before init → ReferenceError

The Temporal Dead Zone (TDZ)#

The TDZ is the span from the start of a block until a let/const variable's declaration line. Accessing the variable there throws ReferenceError. It exists so that const can be guaranteed initialized before use and to catch use-before-declare bugs. 🎯

{
  // TDZ for `a` starts here
  // console.log(a);  // ReferenceError
  let a = 1;          // TDZ ends
  console.log(a);     // 1
}

Scope#

graph TD
    G[Global scope] --> F[Function scope]
    F --> B[Block scope]
    B -.lookup chain.-> F
    F -.lookup chain.-> G
  • Global — outermost. In browsers, top-level var/function attach to window.
  • Function — each function call creates a new scope.
  • Block{ } creates a scope for let/const (not var).
  • Lexical scoping — inner scopes can read outer variables; lookups walk outward through the scope chain. (Not "dynamic scoping" — where you are written matters, not who called you.)

⚠️ Classic loop trap (a favourite interview question 🎯):

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);   // 3, 3, 3  — one shared `i`
}
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0);   // 0, 1, 2  — fresh binding per iteration
}

let creates a new binding each iteration, which is why closures capture the value you expect.

Reassignment vs mutation, immutability#

const prevents reassignment of the binding, not mutation of the value:

const arr = [1, 2];
arr.push(3);        // ✅ fine — mutating the array
// arr = [4];       // ❌ TypeError — reassigning the binding

const obj = { a: 1 };
obj.a = 2;          // ✅ fine
Object.freeze(obj); // now obj.a = 3 silently fails (throws in strict mode)

Object.freeze is shallow. For deep immutability you recurse, or use a library (Immer, immutable.js), or structuredClone + freeze.

Memory behaviour#

ELI12. Small simple values (numbers, strings, booleans) are stored directly in the variable's box. Big values (objects, arrays, functions) live somewhere else (the heap), and the variable box holds a ticket (reference) to them.

let a = 10;
let b = a;      // b copies the value → 10
b = 20;
console.log(a); // 10 (unaffected)

let o1 = { n: 1 };
let o2 = o1;    // o2 copies the reference (same object)
o2.n = 99;
console.log(o1.n); // 99 (both point to one object)
graph LR
    subgraph Stack
    a["a = 10"]
    b["b = 20"]
    o1["o1 = ref#1"]
    o2["o2 = ref#1"]
    end
    subgraph Heap
    obj["{ n: 99 }"]
    end
    o1 --> obj
    o2 --> obj

This is the single most important idea behind "why did changing o2 also change o1?" — the answer is reference semantics. 🎯

Interview Q&A#

Q1. What is the TDZ and why does it exist? The Temporal Dead Zone is the span from the start of a block to a let/const's declaration line, during which accessing the variable throws ReferenceError. It exists so const can be guaranteed initialized before use and to catch use-before-declare bugs that var's silent undefined would hide.

Q2. Predict the output of the var/let loop above and explain. The var loop logs 3, 3, 3 because all callbacks close over one shared function-scoped i, which is 3 by the time they run. The let loop logs 0, 1, 2 because let creates a fresh block-scoped binding per iteration.

Q3. Does const make an object immutable? What does it actually protect? No. const only prevents reassignment of the binding, not mutation of the value — const obj = {}; obj.a = 1 is fine. For (shallow) immutability of the value use Object.freeze; deep immutability needs recursion or a library.

Q4. Explain hoisting differences between var, let, function declarations, and function expressions. var is hoisted and initialized to undefined (usable early, but undefined). let/const are hoisted but stay uninitialized in the TDZ (throw if accessed early). Function declarations are fully hoisted (callable before their line). A function expression follows its variable's rules — hoisted as var/let but the function value isn't assigned until execution reaches it.

Practice:

  • Rewrite a var-based loop that logs 0..4 asynchronously so it works, in three different ways (let, IIFE, .forEach).
  • Deep-freeze a nested object without a library.