JAVASCRIPT / VARIABLES: VAR, LET, AND CONST
Block scope with let and const
Reason about where a let or const name is visible, shadow names safely inside nested blocks, and use per-iteration loop bindings on purpose.
What you will learn
- Name the exact block a let or const belongs to: the nearest enclosing braces.
- Shadow an outer name inside a block without touching the outer binding.
- Get one fresh binding per iteration from for (let i = 0; ...) for closures.
- Declare in the outer block when a value must outlive an if or else branch.
Understanding Block scope with let and const
A block is any pair of braces used as a statement: the body of an if, else, for, while, try, or catch, a function body, or a bare { ... } written on its own. A let or const declaration binds its name to the innermost block that encloses it, and that binding stops existing once execution leaves the block. The mental model that holds up is a box with the name written on the inside wall: code inside can read it, code outside cannot, and there is no way to reach in from outside.
When code reads a name, the engine looks in the current block, then the block around it, and so on outward until it finds a binding or runs out of scopes and throws a ReferenceError. Because the search stops at the first match, an inner let x shadows an outer x for the length of that block; the outer binding is not overwritten or copied, it is only invisible for those lines. That is why shadowing is safe but an accidental shadow is confusing: assignments inside the block change the inner binding, and the outer value still holds whatever it held before.
Loops add one twist worth knowing. The declaration in for (let i = 0; i < 3; i++) belongs to the loop's own scope, and the engine creates a fresh i for each iteration, copying the previous value in before running the update. A function created in the body therefore captures that iteration's binding and keeps it after the loop moves on. In everyday code this pushes you toward declaring each name in the smallest block that needs it, and declaring with let in the enclosing block only when a value has to survive past a branch.
const level = "outer";
{
const level = "inner";
console.log("inside block:", level);
}
console.log("outside block:", level);
if (true) {
let hidden = "only here";
console.log("inside if:", hidden);
}
try {
console.log(hidden);
} catch (err) {
console.log(err.constructor.name + ":", err.message);
}let and const attach a name to the nearest enclosing pair of braces, so the name lives and dies with that block.
Worked examples
One binding per loop iteration
Shows that a let in the loop head produces a separate binding each pass, which functions created in the body capture.
const withLet = [];
for (let i = 0; i < 3; i++) {
withLet.push(() => i);
}
console.log(withLet.map(f => f()).join(" "));
const withVar = [];
for (var j = 0; j < 3; j++) {
withVar.push(() => j);
}
console.log(withVar.map(f => f()).join(" "));Example explained
Line 1for (let i = 0; ...) creates a new i for every iteration, so each arrow closes over a different binding.
Line 2The arrows only run inside .map, long after the loop ended, and each one still sees the value its own binding held.
Line 3var j has a single binding for the whole surrounding function, so all three arrows read the same j, which is 3 once the loop stops.
Two blocks, one reused name
Demonstrates that separate blocks can reuse a const name and that nothing is left behind afterwards.
const counts = [];
{
const rows = ["a", "b"];
counts.push(rows.length);
}
{
const rows = ["c", "d", "e"];
counts.push(rows.length);
}
console.log(counts.join(","));
console.log(typeof rows);Example explained
Line 1Each bare { ... } is its own block, so the second const rows is a brand new binding rather than a redeclaration of the first.
Line 2counts is declared outside both blocks, so both can push into it and the collected data outlives them.
Line 3After the blocks there is no rows binding at all; typeof returns the string "undefined" for a name that was never declared instead of throwing, so it cannot be used to prove a name is in scope.
A value that must outlive a branch
Contrasts declaring inside if and else branches with declaring once in the enclosing block.
function classifyBroken(score) {
if (score >= 50) {
let grade = "pass";
} else {
let grade = "fail";
}
try {
return grade;
} catch (err) {
return err.message;
}
}
function classify(score) {
let grade;
if (score >= 50) {
grade = "pass";
} else {
grade = "fail";
}
return grade;
}
console.log(classifyBroken(70));
console.log(classify(70), classify(20));Example explained
Line 1Each let grade belongs to its own branch block and is gone at that branch's closing brace, so the later return grade has no binding to resolve.
Line 2Nothing complains while the file is parsed; the failure is a runtime ReferenceError, caught here only to keep the output readable.
Line 3classify declares grade once in the function block, so both branches assign the same binding and the value survives the if.
Important notes
All case clauses of a switch share one block, so let x in two different cases is a redeclaration SyntaxError; wrap a case body in its own { } to give it a private scope.
The loop head belongs to the loop's scope, not the surrounding one: after for (let i = 0; i < 3; i++) { } the name i is unavailable, even though it was written outside the body braces.
Common mistakes
Declaring let total inside an if block and reading total after the closing brace: the file parses fine and only throws ReferenceError: total is not defined when that path actually runs.
Using typeof name to test whether a block-scoped name leaked out: it answers "undefined" for a name that was never declared, so a scope error looks like a missing value.
Using a let declared inside a loop body as a running total: it is created fresh each iteration, so the total resets to its initializer every pass and the final result reflects only the last item.
Try it yourself
Change, predict, then run
In a browser editor write classify(score) that assigns "pass" or "fail" to a let declared inside each branch of an if/else and returns it after the if, run it, read the ReferenceError, then fix it by declaring the variable above the if. Then push three arrows that return the counter from a let loop and from a var loop, and compare the two arrays of results.
Open the JavaScript workspaceCheck your understanding
const fns = []; for (let i = 0; i < 3; i++) { if (i === 1) { const i = 99; fns.push(() => i); } else { fns.push(() => i); } } console.log(fns.map(f => f()).join(",")); What is logged?
- 0,99,2
- 0,1,2
- 3,3,3
- SyntaxError: Identifier 'i' has already been declared
Show answer
The if branch is a separate block, so const i = 99 shadows the loop binding there instead of clashing with it; a redeclaration error would need two declarations in the same block, which rules out the SyntaxError option. Iterations 0 and 2 take the else branch and each closes over its own per-iteration binding, giving 0 and 2, while the middle arrow sees the shadowed 99. The 3,3,3 answer assumes one shared binding, which is what var i would produce, and it also ignores the shadowing.