Scope & var/let/const (the TDZ)

Reviewed & published by Brayan K

Scope answers one question: where can I see this variable? Getting it right is the difference between a clean program and one full of names that leak, clash, or mysteriously hold the wrong value.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

We'll compare var, let, and const head to head, walk the scope chain, revisit the Temporal Dead Zone, and finally explain that famous loop bug.

What You'll Learn in This Lesson

📚 Prerequisites: Read Hoisting first — scope and hoisting are two sides of the same coin. Comfort with functions helps too.

💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:

🏢 Real-World Analogy: Scope is like rooms in an office building:

1️⃣ Global, Function & Block Scope

A variable's scope is the region of code where its name is valid. There are three levels: global (the whole program), function (inside one function), and block (inside any { } — an if, a loop, a bare block). let and const are block-scoped; var only respects functions.

const planet = "Earth"; // global

function explore() {
  const ship = "Voyager"; // function scope
  if (true) {
    const fuel = 100;     // block scope (only inside this if)
    console.log(planet, ship, fuel); // ✅ Expected output: Earth Voyager 100
  }
  // 'fuel' does not exist out here
  console.log(typeof fuel); // ✅ Expected output: undefined
}

explore();

2️⃣ var Leaks Out of Blocks

Here's the headline difference. Declare a var inside an if or loop and it escapes the block — it's visible in the whole function. A let in the same place stays politely contained. This leak is a classic source of subtle bugs.

function demo() {
  if (true) {
    var leaky = "I escaped!";
    let trapped = "I stay inside";
    console.log(trapped); // ✅ Expected output: I stay inside
  }

  // var leaked out to the whole function:
  console.log(leaky); // ✅ Expected output: I escaped!

  // let did NOT leak (guarded so the demo keeps running):
  try {
    console.log(trapped);
  } catch (e) {
    console.log(e.constructor.name); // ✅ Expected output: ReferenceError
  }
}

demo();

3️⃣ The Scope Chain

When you reference a name, JavaScript searches the current scope, then the one enclosing it, and so on outward until it hits global. The first match wins — a phenomenon called shadowing when an inner name hides an outer one of the same name.

const level = "global";

function outer() {
  const level = "outer"; // shadows the global 'level'
  function inner() {
    // No local 'level', so it climbs: inner -> outer (found!)
    console.log(level); // ✅ Expected output: outer
  }
  inner();
}

outer();
console.log(level); // ✅ Expected output: global  (outer's 'level' never leaked)

🎯 Your Turn #1 — Keep It Contained

Change the declaration so secret stays inside the block and the outer log throws.

function vault() {
  if (true) {
    // 👉 replace ___ with let or const so 'secret' is block-scoped
    ___ secret = "1234";
    console.log(secret); // ✅ Expected output: 1234
  }
  try {
    console.log(secret);
  } catch (e) {
    console.log(e.constructor.name); // ✅ Expected output: ReferenceError
  }
}
vault();

4️⃣ The Famous Loop Bug — var vs let

This is the interview classic. With var, the loop shares a single i, so every deferred callback reads its final value. With let, each iteration gets a fresh binding, so callbacks capture the value they had at the time. Same loop, one keyword apart.

// ❌ With var: one shared binding — all callbacks see the final value
const withVar = [];
for (var i = 0; i < 3; i++) {
  withVar.push(() => i);
}
console.log(withVar.map(fn => fn())); // ✅ Expected output: [ 3, 3, 3 ]

// ✅ With let: a fresh binding per iteration
const withLet = [];
for (let j = 0; j < 3; j++) {
  withLet.push(() => j);
}
console.log(withLet.map(fn => fn())); // ✅ Expected output: [ 0, 1, 2 ]

5️⃣ const Protects the Binding, Not the Value

A common surprise: you can mutate a const object. const only forbids pointing the name at something new — it doesn't freeze the contents. To lock the contents too, reach for Object.freeze().

const user = { name: "Mara", role: "guest" };

// ✅ Allowed: changing a property (mutating the value)
user.role = "admin";
console.log(user.role); // ✅ Expected output: admin

// ❌ Not allowed: reassigning the binding (guarded so demo runs)
try {
  // user = {}; would throw "Assignment to constant variable"
  eval("user = {}");
} catch (e) {
  console.log(e.constructor.name); // ✅ Expected output: TypeError
}

// Lock the contents too:
const frozen = Object.freeze({ pi: 3.14 });
frozen.pi = 99; // silently ignored in non-strict, throws in strict
console.log(frozen.pi); // ✅ Expected output: 3.14

🎯 Your Turn #2 — Fix the Loop

Swap one keyword so the callbacks capture 0, 1, 2 instead of all threes.

const callbacks = [];
// 👉 replace ___ with the keyword that gives each iteration its own binding
for (___ k = 0; k < 3; k++) {
  callbacks.push(() => k);
}

console.log(callbacks.map(fn => fn())); // ✅ Expected output: [ 0, 1, 2 ]

🧩 Reorder Challenge

These lines are scrambled. Reorder them so the inner function can read outer and it logs hi.

Why: inner reads message via the scope chain at call time, so message (a const) must be initialised before inner() runs.

🧠 Quick Recall

Predict the output before revealing the answer.

1 — var is function-scoped, so it leaks out of the if block.

2 — const blocks reassigning the binding, but the object's contents can still change.

in out — the inner x shadows the outer one inside f; outside, the global x is unchanged.

Common Errors & Fixes

Quick Reference — var / let / const

KeywordScopeReassign?Before declaration
varFunction✅ Yesundefined
letBlock✅ YesTDZ → ReferenceError
constBlock❌ NoTDZ → ReferenceError

Rule of thumb: default to const, use let when you must reassign, avoid var.

Lesson Complete — Scope & var/let/const!

Practice quiz

Which keyword is function-scoped rather than block-scoped?

  • let
  • const
  • var
  • block

Answer: var. var is function-scoped, so it ignores inner { } blocks; let and const are block-scoped.

What does this log? function demo() { if (true) { var leaky = 'I escaped!'; } console.log(leaky); } demo();

  • I escaped!
  • ReferenceError
  • undefined
  • ''

Answer: I escaped!. var leaks out of the if block to the whole function, so leaky is still 'I escaped!' after the block.

What is logged? const withVar = []; for (var i = 0; i < 3; i++) { withVar.push(() => i); } console.log(withVar.map(fn => fn()));

  • [ 0, 1, 2 ]
  • [ 1, 2, 3 ]
  • [ 2, 2, 2 ]
  • [ 3, 3, 3 ]

Answer: [ 3, 3, 3 ]. var shares one binding for the whole loop, so every callback reads the final value 3.

Replace var with let in that loop: for (let j = 0; j < 3; j++) { withLet.push(() => j); }. What does it log?

  • [ 3, 3, 3 ]
  • [ 0, 1, 2 ]
  • [ 1, 2, 3 ]
  • ReferenceError

Answer: [ 0, 1, 2 ]. let creates a fresh binding per iteration, so each callback captures its own value: 0, 1, 2.

What does this log? let x = 'out'; function f() { let x = 'in'; return x; } console.log(f(), x);

  • in out
  • out out
  • in in
  • out in

Answer: in out. The inner x shadows the outer x inside f (so f returns 'in'); the outer x is unchanged ('out').

What does this log? const user = { name: 'Mara' }; user.name = 'Ada'; console.log(user.name);

  • Mara
  • TypeError
  • Ada
  • undefined

Answer: Ada. const blocks reassigning the binding, but the object's properties can still be mutated, so name becomes 'Ada'.

What happens here? const frozen = Object.freeze({ pi: 3.14 }); frozen.pi = 99; console.log(frozen.pi);

  • 99
  • 3.14
  • undefined
  • NaN

Answer: 3.14. Object.freeze locks the contents, so the assignment has no effect and pi stays 3.14.

When a name isn't found in the current scope, JavaScript looks where next?

  • The global scope, skipping any scopes in between
  • Any inner (nested) scope
  • It immediately throws a ReferenceError
  • The nearest enclosing (outer) scope, working outward

Answer: The nearest enclosing (outer) scope, working outward. The scope chain searches the current scope, then each enclosing scope outward, until it reaches global.

What is logged? console.log(v); let v = 1;

  • undefined
  • ReferenceError (TDZ)
  • 1
  • null

Answer: ReferenceError (TDZ). A let is in the Temporal Dead Zone until its declaration line, so reading it early throws a ReferenceError.

What is logged? function vault() { if (true) { let secret = '1234'; } try { return secret; } catch (e) { return e.constructor.name; } } console.log(vault());

  • 1234
  • undefined
  • ReferenceError
  • TypeError

Answer: ReferenceError. let is block-scoped, so secret does not exist outside the if block and accessing it throws a ReferenceError.

Continue this course

Frequently asked questions

What's the practical difference between block scope and function scope?

let and const are block-scoped: they only exist inside the nearest pair of curly braces — an if, a for loop, or any { } block. var is function-scoped: it ignores those inner braces and lives in the whole enclosing function. That difference is why a var declared inside an if leaks out, while a let does not.

Why does the classic for loop with var print the same number every time?

Because var has one shared binding for the whole loop. By the time the asynchronous callbacks run, the single i has already reached its final value, so they all read the same number. let creates a fresh binding per iteration, so each callback captures its own value. Switching var to let is the one-word fix.

What is the scope chain?

When you use a name, JavaScript looks in the current scope first; if it's not there it looks in the enclosing scope, then the next one out, all the way to the global scope. That nested lookup is the scope chain. Inner scopes can read outer variables, but outer scopes cannot see inner ones.

Is const really constant?

const stops you REASSIGNING the binding — you can't point the name at a different value. It does NOT freeze the value itself. A const object or array can still have its properties or elements changed. If you need the contents locked too, use Object.freeze().

Should I default to let or const?

Default to const. Reach for let only when you genuinely need to reassign the variable later. Using const by default signals intent (this won't change), prevents accidental reassignment, and makes code easier to reason about. Avoid var in new code entirely.

Related lessons