Variables have a territory
Scope is the region of code where a variable exists. The rule in modern JavaScript is short: a let or const variable lives inside the nearest pair of curly braces { } that contains it, and that includes function bodies, if blocks, and loop bodies alike.
Visibility runs in one direction only:
- Inner code can see outward. A function body can read variables declared outside it.
- Outer code cannot see inward. A variable born inside braces is invisible outside them.
const site = "Hack University"; function welcome(name) { const message = `Welcome to ${site}, ${name}`; // site is visible here return message; } // message is NOT visible here
site is declared outside the function, so the body can read it freely. message is declared inside, so the moment the closing brace arrives the name stops meaning anything.
Declaring a new variable inside a function with the same name as an outer one creates a separate inner variable that temporarily hides the outer one. That is called shadowing, and it is the usual explanation when a variable stubbornly refuses to change.
Reaching outward against shadowing
Two functions that look like they do similar things and do not. increment reaches out to the shared counter, while reset declares a counter of its own, so the outer one never changes.
let counter = 0; function increment() { counter = counter + 1; } increment(); increment(); console.log(counter); function reset() { let counter = 0; counter = 99; } reset(); console.log(counter);
Output
2 2
increment has no declaration in it at all, so counter = counter + 1 refers to the variable declared outside, and two calls leave it at 2.
reset starts with let counter = 0;, and that single keyword is what changes everything. It creates a brand new variable that exists only inside reset and hides the outer one for the length of that function. Setting it to 99 affects only the inner copy, which disappears when the function returns. The outer counter is still 2, which is why the second printed line matches the first.
That is shadowing, and it is behind a large share of the confusion about variables that refuse to change. The cure is usually to notice the stray let or const.
Getting a value out of a function properly
This program crashes with ReferenceError: msg is not defined, because msg exists only inside makeMessage and the console.log sits outside it.
function makeMessage() { const msg = "done"; } makeMessage(); console.log(msg);
The fix is not to move the variable outward but to hand the value back through return, and to catch it at the call site:
function makeMessage() { const msg = "done"; return msg; } const result = makeMessage(); console.log(result);
Output
done
Two edits make that work: return msg; inside the function, and const result = makeMessage(); at the call, since a returned value is lost unless something catches it.
The principle underneath is worth more than the fix. Outer code should receive data from a function rather than reach inside it, which keeps the function's internals free to change and makes it usable from anywhere. Returning values is the interface, and shared outer variables are a shortcut that stops working as soon as the program grows.
Reading a block variable after its block ends
Reading a const two lines after the if block that declared it throws ReferenceError: x is not defined.
let and const live only inside the nearest enclosing braces, and an if block's braces count just as much as a function's do. Once the block closes, the variable is gone, and the name means nothing to the code that follows.
When the value is needed afterwards, declare it before the block and assign inside it:
let status = "unknown"; if (score >= 70) { status = "pass"; } console.log(status);
The declaration now sits in the outer scope, so it outlives the if, and the assignment inside the block reaches out to it exactly as increment did in the previous example. This is one of the few situations where let is clearly the right choice over const.