Lesson 9 · 14 min read

Block Scope and Shadowing

A block is anything inside { }. Simple idea — but var and let react to that boundary in completely different, and sometimes dangerous, ways. What a block creates in memory, how nested blocks chain, and every shadowing rule — legal, illegal, and the function-boundary exception.

On this page

  1. Concept
  2. Key words in plain English
  3. Why this matters
  4. The meeting room door — an analogy
  5. What counts as a block
  6. What a block actually creates in memory
  7. var vs. let/const inside a block
  8. Nested blocks — the chain, one brace at a time
  9. Shadowing — when names repeat
  10. Why var shadowing is dangerous
  11. Illegal shadowing — the one combination that throws
  12. The function-boundary exception — why the same pair is legal there
  13. Shadowing parameters and built-ins
  14. The switch trap — one block for every case
  15. When shadowing is a smell, and when it is fine
  16. See it live — DevTools
  17. Common misconceptions
  18. Interview questions

Concept

A block is simply any code between { } — an if, a for loop, or just a bare { } on its own. let and const treat that boundary as a real wall. var ignores it completely. That one difference is the entire lesson — and it has a sharp edge called shadowing that can genuinely break your code if you don't know it's there.

Key Words In Plain English

  • Scope is the part of the code where a name can be used. Outside its scope, the name does not exist.
  • Block is a group of statements inside curly braces. It is a scope for let and const, but not for var.
  • Lexical Environment is the engine's internal record of the variables of one scope, plus a link to the outer scope. “Lexical” means decided by where the code is written.
  • Scope chain is the list of scopes from the inner one out to Global. When you use a name, the engine looks in the inner scope first, then the next one out, and stops at the first match.
  • Shadowing is when an inner scope declares a name that an outer scope already has. The inner one hides the outer one while the code is inside.
  • Hoisting is the engine reserving memory for declarations before any line runs. var is hoisted to the top of its function, not to the top of its block.
  • Temporal Dead Zone (TDZ) is the time between a let/const name being reserved and its declaration line running. Reading the name in that time throws a ReferenceError.
  • Parameter is a name in a function's definition, such as guestName in function checkIn(guestName). The value you pass when you call the function is the argument.
  • SyntaxError is an error for code that is not valid JavaScript. The engine finds it before running any line.
  • Linter is a tool that reads your code and warns about mistakes and risky patterns without running it. ESLint is the most common one for JavaScript.

Why This Matters

  • It explains why the same variable name can safely repeat inside nested if blocks with let, but silently corrupts data with var.
  • It's a very common real bug: a var inside a loop or an if block quietly overwriting a variable you thought was separate.
  • It's a frequent interview trap — predicting the output of nested blocks with repeated names.
  • It closes the loop on Lessons 7 and 8: a block is a link in the scope chain, with its own TDZ for anything declared inside it. Once you can see the environment a block creates, every shadowing rule in this lesson follows from Lesson 7's “nearest match wins”.

The Meeting Room Door — An Analogy

One respects the door, one walks through the wall

A meeting room has a door. Anything written on the whiteboard inside that room, using let/const rules, stays inside — step outside, and it's gone, exactly like Lesson 3.

But var doesn't care that a door exists at all. Whatever it writes ends up on the main office notice board outside, regardless of which room it was written from. It walks straight through the wall.

What Counts As A Block

blocks.js
{
  // a bare block — still a block
}

if (true) {
  // this counts too
}

for (let i = 0; i < 3; i++) {
  // and this
}

Any { } counts — it doesn't need to be attached to an if or a loop.

Two braces that look like blocks but are not: the { } of an object literal ({ name: "Aditi" }) is a value, not a scope; and the body of a function is technically a function scope — it contains everything a block scope does, plus it is the one boundary var respects.

What A Block Actually Creates In Memory

A block is not a scope by decoration; it is a scope because entering it can create a real Lexical Environment (Lesson 7): a memory record for the block's own let/const/class declarations (and function declarations written inside the block), plus an outer reference to the environment the block sits inside. Leaving the block discards that environment. Everything else in this lesson is a consequence of that one object existing — or not.

creates.js
{
  // no let/const here → the engine creates no environment for this block
  var x = 1;
}

{
  let y = 2;   // → a real block environment is created on entry, discarded on exit
}

The first block declares nothing block-scoped, so the engine has nothing to put in a block environment and creates none; the var was hoisted to the enclosing function long before the braces were reached. The second block declares a let, so on entry it gets an environment holding y (locked in its TDZ until line 7 runs — Lesson 8), and on exit that environment is dropped.

This is also why a block's bindings are created when the block is entered, not when the file loads. A function's memory phase reserves its own vars and function declarations; the lets inside a nested block wait until execution actually reaches the {. The stepper in the next section shows the block environment appearing at exactly that line.

var vs. let/const Inside A Block

var-vs-let.js
{
  var a = 10;
  let b = 20;
}

console.log(a); // 10 — var walked straight out
console.log(b); // ReferenceError — let stayed inside, now gone

var was never block-scoped to begin with — it only respects function boundaries (Lesson 3). { } means nothing to it.

Outer scope (function or global)a → 10
block { }
var a = 10↗ escapes through the wall
let b = 20stays inside · gone at }
a escapes → 10
b stays trapped,
gone once block ends
var ignores the block wall entirely. let/const are contained by it. · block wall

Nested Blocks — The Chain, One Brace At A Time

Blocks nest, and each nested block that declares something gets its own environment, whose outer reference points at the enclosing block's environment. The result is the same scope chain Lesson 7 built out of functions — just with braces as the links.

nested.js
let floor = 1;
{
  let wing = "north";
  {
    let room = 101;
    console.log(room, wing, floor); // 101 north 1
  }
}
step 1 / 7
Running
Inside the inner block

Three environments are alive, each created when its braces were entered, each with an outer reference to the one around it. Line 6 needs three names.

nested.js
1let floor = 1;
2{
3 let wing = "north";
4 {
5 let room = 101;
6 console.log(room, wing, floor);
7 }
8}
(console)
Scope chain · inner → outer
inner block { }
  • room101
↓ outer reference
outer block { }
  • wing"north"
↓ outer reference
Global
  • floor1
↓ outer reference
null
Step through the three lookups on line 6. Each block is a full link in the scope chain; each name is found at the nearest level that declares it. · interactive

Nothing new is happening here: room is found locally, wing one hop up, floor two hops up. The point is that a block is a first-class link in that chain, indistinguishable from a function's scope as far as lookup is concerned. Which is exactly why shadowing behaves the way it does.

Shadowing — When Names Repeat

Shadowing is what happens when an inner scope declares a variable with the same name as one in an outer scope. With let/const, this is completely safe:

shadow-let.js
let discount = 100;
{
  let discount = 20; // a genuinely separate variable, in its own block
  console.log(discount); // 20
}
console.log(discount); // 100 — untouched

Two totally separate discounts, living in two separate memory spaces. The inner one temporarily “shadows” the outer one, but never touches it.

In chain terms: the block environment has its own discount, so any lookup from inside the block stops at the first level — nearest match wins — and the outer binding is never consulted. It is not hidden by any special rule; it is one link further down a chain the lookup never walks.

Why var Shadowing Is Dangerous

shadow-var.js
var discount = 100;
{
  var discount = 20; // NOT a separate copy — this is the same variable
}
console.log(discount); // 20, not 100!

This isn't really “shadowing” at all — it's the exact same discount being overwritten. Because var ignores the block wall, there was only ever one discount in this whole scope. Writing var discount again inside the block doesn't create a new one — it just reassigns the one that already existed.

step 1 / 7
Memory phase
Memory phase

let: the outer discount is reserved (locked until line 1). The block's own discount does not exist yet — a block's bindings are created when the block is entered. var: one discount in the outer scope, undefined. The var on line 3 is the same name in the same function scope, so it reserves nothing new.

let · two separate slots
1let discount = 100;
2{
3 let discount = 20;
4 console.log(discount);
5}
6console.log(discount);
memory · 1 slot
outer scope · discount🔒 TDZ
(console)
var · one slot, overwritten
1var discount = 100;
2{
3 var discount = 20;
4 console.log(discount);
5}
6console.log(discount);
memory · 1 slot
outer scope · discountundefined
(console)
Step through both files together. Count the slots: let makes a second one inside the block and throws it away; var only ever had one, and line 3 overwrites it. · interactive

Illegal Shadowing — The One Combination That Throws

illegal.js
let discount = 100;
{
  var discount = 20; // SyntaxError: Identifier 'discount' has already been declared
}

This one specifically fails. var tries to attach itself to the nearest function/global scope — but a let with the same name already exists right there, and the two can't coexist. Every other combination is fine:

OuterInner (in a nested block)Result
letletAllowed — genuinely separate variables
varletAllowed — let stays contained in the block
varvarAllowed — but it's really the same variable (see above)
letvarSyntaxError — var collides with the outer let

The mechanism is Lesson 8's redeclaration rule, seen from a new angle. The engine hoists var discount out of the block to the nearest function scope — here, Global. Global already holds a let discount. That is two declarations of one name in one scope, which is exactly the duplicate-let SyntaxError — caught at parse time, before any line runs. The braces did not protect the var, because braces never do.

The Function-Boundary Exception — Why The Same Pair Is Legal There

Read the previous rule carefully and a loophole appears: the collision happens only because the var hoists to the same scope as the let. Put a function boundary between them and the var hoists to the function instead — a different scope, no collision, ordinary shadowing.

boundary.js
let discount = 100;

function applyOffer() {
  var discount = 20;     // fine — this var belongs to applyOffer, not to Global
  console.log(discount); // 20
}

applyOffer();
console.log(discount);   // 100 — the outer let was never in the same scope

Same keywords, same names, same nesting — but the inner declaration is inside a function body rather than a bare block, so var discount belongs to applyOffer's own memory. The outer let is one link up the chain, untouched. So the precise statement of the illegal case is: a var may not hoist into a scope that already has a let/const of the same name. A block cannot stop that hoisting; a function can.

Shadowing Parameters And Built-Ins

A function's parameters live in the same scope as the top level of its body. That makes a parameter behave like a let declared just before the first line of the function body — so redeclaring it with let at the top level of the body is a duplicate declaration, while shadowing it inside a nested block is legal.

params.js
function checkIn(guestName) {
  let guestName = "Rahul";     // SyntaxError — the parameter already owns this name
}

function checkOut(guestName) {
  {
    let guestName = "Rahul";   // fine — a nested block is a different scope
    console.log(guestName);    // "Rahul"
  }
  console.log(guestName);      // the original argument, untouched
}

The same logic covers built-in names. let undefined = 5; at the top level of a classic browser script is a SyntaxError, because undefined is already a property of the global object; inside a block or a function it is legal, and creates a genuinely separate binding that shadows the real one for that scope. Legal is not the same as wise — shadowing undefined, name, length or event is a reliable way to confuse the next person to read the code.

The switch Trap — One Block For Every Case

A switch statement has exactly one block: the braces after switch (…). case labels are labels — they do not open a scope. So two cases that each declare let rate are two declarations in one scope, and that is a SyntaxError before the file runs.

switch.js
switch (roomType) {
  case "single":
    let rate = 100;   // declared in the switch's one shared block
    break;
  case "double":
    let rate = 180;   // SyntaxError: Identifier 'rate' has already been declared
    break;
}

// the fix: give each case its own braces
switch (roomType) {
  case "single": {
    let rate = 100;
    break;
  }
  case "double": {
    let rate = 180;
    break;
  }
}

The fix is the general one: if you want a scope, write braces. Wrapping each case body in { } gives every case its own block environment, and the shared-block problem disappears. Linters flag the unwrapped version as no-case-declarations.

When Shadowing Is A Smell, And When It Is Fine

Everything above describes what the engine allows. What you should do is a narrower question.

  • Fine: short-lived names in small, obviously separate scopes — the i of two sibling loops, an error in two separate catch blocks, a callback parameter that reuses a conventional name.
  • Smell: shadowing a name from an enclosing scope that is still in use around the inner scope. Anyone reading the inner code has to remember which discount they are looking at, and a later refactor that removes the inner declaration silently changes which binding the inner code touches — from a private copy to the outer original.
  • Always a bug: var “shadowing” inside a block. It looks like a separate variable and is not. Modern code avoids var entirely (Lesson 8), which removes this class of bug outright.
Practical rule

Turn on no-shadow in your linter and treat every warning as a naming decision: either the inner thing really is different — give it a different name — or it is the same thing, and you did not need a new declaration at all.

See It Live — DevTools

  1. Paste the var-shadowing example

    The one under “Why var shadowing is dangerous,” into the console.

  2. Set a breakpoint inside the block

    On the line var discount = 20;.

  3. Check the Scope panel

    Notice there's only one discount listed, under the outer scope — not two. Stepping past this line updates that single value from 100 to 20, live.

  4. Repeat with the let version

    This time the Scope panel shows two genuinely separate entries while paused inside the block — the outer discount untouched, a second one local to the block.

  5. Look for the Block entry itself

    In the let version the panel gains a section titled Block while you are paused inside the braces, holding the inner discount. Step past the closing } and the whole Block section vanishes — that is the block environment being discarded. In the var version there is never a Block section at all, because no environment was ever created.

Common Misconceptions

“Shadowing always means two separate variables.” Only with let/const. A var “shadow” is really just the same variable being reassigned, since var was never block-scoped to begin with.

“You can shadow any variable with any keyword.” Not quite — shadowing a let with a var in a nested block throws a SyntaxError, because the escaping var collides with the existing let.

“A bare { } block does nothing on its own.” It still creates a real scope boundary for let/const, even with no if or loop attached to it.

“let outside, var inside is always illegal.” Only when the var hoists into the same scope as the let — a bare block. Put the var inside a function and it hoists to that function instead, which is ordinary, legal shadowing.

“Each case in a switch is its own block.” case is a label, not a scope. All cases share the switch's single block unless you add braces yourself.

Interview Questions

What is a block in JavaScript?

Any code inside { } — an if, a loop, or a standalone { } on its own.

Does var respect block scope?

No — var only respects function scope. A var declared inside a block is still accessible outside it.

Is var shadowing “real” shadowing?

Not genuinely — since var ignores blocks, there was only ever one variable. Declaring it again inside a block just reassigns the same one, rather than creating a separate copy.

What happens if you shadow an outer let with an inner var in a nested block?

A SyntaxError — the escaping var tries to attach to the same scope the outer let already occupies, and the two can't coexist.

Is shadowing a let with another let allowed?

Yes — that creates two genuinely separate, independent variables, one per scope.

What does a block actually create at runtime?

If it contains a let, const, class or function declaration, entering the block creates a new Lexical Environment (a memory record plus an outer reference to the surrounding scope), and leaving it discards that environment. A block with only vars or statements creates nothing — there is nothing block-scoped to hold.

How does a lookup inside three nested blocks find a variable declared at the top?

Exactly as it does through nested functions (Lesson 7): own block first, then the outer reference to the enclosing block, then the next, until Global. A block's environment is an ordinary link in the scope chain.

Why is let outside / var inside a block a SyntaxError, but let outside / var inside a function fine?

The var hoists to the nearest function scope. In a block, that is the very scope where the outer let already lives — two declarations of one name in one scope, which is illegal. Inside a function, the var lands in that function's own scope, a different one from the outer let, so it is ordinary legal shadowing.

Can you redeclare a function parameter with let in the function body?

Not at the top level of the body — parameters and the body's top-level let/const share one scope, so it is a SyntaxError. Inside a nested block within the body it is allowed, as normal shadowing.

Why does declaring the same let in two switch cases throw?

A switch statement has one block for all its cases — case labels do not open scopes. Two let rate in different cases are two declarations in the same scope. Wrap each case body in its own { } to give it a scope.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related series