JAVASCRIPT / SCOPE, CLOSURES, AND THIS
Lexical scope and the scope chain
Trace how JavaScript resolves a variable name by walking outward through the scopes that enclose it, and why the calling function's variables stay invisible.
What you will learn
- Resolve any identifier by walking outward to the first scope that declares that name
- Explain why a function cannot see the local variables of the function that called it
- Predict which binding wins when an inner declaration shadows an outer one
- Recognise that a declaration anywhere in a scope shadows the outer name for that scope
Understanding Lexical scope and the scope chain
JavaScript decides what a name means from where the code sits in the source text, not from what happens to be running when that line executes. The nesting of functions and blocks is fixed the moment you stop typing, so an engine can work out which declaration each identifier points at before the program ever runs; that is what "lexical" means here. Some languages use dynamic scoping, where a function sees the variables of whoever called it. JavaScript deliberately does not, which is why you can call a helper from anywhere without changing what its identifiers mean.
When a function runs, the engine creates a record holding that call's own variables plus one link to the record of the scope the function was defined in. Following those links from the innermost record outward is the scope chain, and a name is resolved by walking it and stopping at the first record that declares that name. The walk only goes outward: an outer scope cannot see into an inner one, and two sibling functions cannot see each other's locals. If the walk runs off the top with no match, reading the name throws a ReferenceError.
Two consequences fall out of this. Shadowing is nothing more than "nearest link wins": an inner `total` hides an outer `total` for that entire scope, including lines written above the declaration, because the binding belongs to the scope rather than to the line, and writing to the inner one leaves the outer untouched. The other consequence is that the call stack and the scope chain are separate structures — the stack says who called whom right now, the chain says where the code was written — so questions about which variable a name refers to are answered by reading indentation, not stack traces.
const level = 'global';
function show() {
console.log('show sees:', level);
}
function nested() {
const level = 'nested';
function deep() {
console.log('deep sees:', level);
}
deep();
show();
}
nested();A function's scope chain is fixed by where the function is written, and names resolve by walking that chain outward to the first matching declaration.
Worked examples
Three links, one winner
Shows lookup stopping at the nearest declaration and shadowing not altering the outer binding.
const label = 'top';
function level1() {
const label = 'one';
function level2() {
console.log(label);
const inner = () => {
const label = 'three';
console.log(label);
};
inner();
console.log(label);
}
level2();
}
level1();
console.log(label);Example explained
Line 1`level2` declares no `label`, so the first log walks one link out to `level1` and finds 'one'.
Line 2`inner` has its own `label`, so its lookup stops there and never reaches `level1`.
Line 3The third log runs after `inner` returned and still prints 'one': shadowing hides a binding, it does not overwrite it.
Line 4The final log sits outside `level1`, where the only reachable `label` is the top-level one.
A later declaration still shadows
Demonstrates that a declaration belongs to the whole scope, so the outer value is unreachable even above the declaration line.
const version = 'outer';
function report() {
try {
console.log(version);
} catch (err) {
console.log('blocked by', err.name);
}
const version = 'inner';
console.log(version);
}
report();Example explained
Line 1`report` declares `version`, so every mention of `version` inside `report` refers to that binding, including the one on the first line.
Line 2That binding exists but is not initialised yet, so the read throws instead of falling through to the outer `version`.
Line 3Once the `const` line has run, the same identifier resolves to the same binding, now holding 'inner'.
Callbacks keep their own chain
Shows that a function passed into another function still resolves names where it was written, not where it is invoked.
function withSeparator(callback) {
const separator = 'pipe';
callback(separator);
}
const separator = 'dash';
withSeparator(sep => {
console.log('lexical:', separator);
console.log('argument:', sep);
});Example explained
Line 1The arrow function is written at the top level, so its outer link points there and `separator` is 'dash'.
Line 2`withSeparator`'s own `separator` is never on the callback's chain, even though the call happens inside `withSeparator`.
Line 3Parameters are the supported way across that boundary: `sep` carries the callee's value in.
Important notes
Failed reads throw, but failed writes may not: in sloppy mode `total = 1` with no `total` on the chain quietly creates a global property, while modules and strict mode turn it into a ReferenceError.
`with` and non-strict `eval` can add bindings that are invisible in the source, which is precisely why they break this static reading of a program and the optimisations built on it.
Common mistakes
Reaching for a variable that belongs to the calling function: it is not on your chain, so you get a ReferenceError or, worse, a same-named global with a stale value.
Reading a name near the top of a function that is declared with let or const further down, expecting the outer value: the inner declaration already shadows it, so the read throws.
Assigning to a shadowed name and expecting the outer variable to change: the write lands on the nearest binding, so the outer value stays as it was and the bug surfaces somewhere unrelated.
Try it yourself
Change, predict, then run
In a browser editor, nest three functions where each one and the top level declare `const stage` with a different value, and log `stage` from the innermost function. Then delete the declarations one at a time from the inside out, predicting each output before you run it.
Open the JavaScript workspaceCheck your understanding
`format` is written at the top level of a module and reads `indent`. It is passed as a callback to `render`, whose body declares `const indent = ' '`. No `indent` exists at the top level. What happens when `render` calls the callback?
- A ReferenceError, because `format`'s chain starts at the top level and never includes `render`'s body
- It uses the two-space indent, because `render`'s scope is on the stack while the callback runs
- It logs undefined, because `indent` is hoisted inside `format` but left unset
- It depends on whether `format` is an arrow function or a function declaration
Show answer
The chain is built from where `format` is written, so it runs top level to global and no `indent` is ever found, which makes the read throw. Option two describes dynamic scoping: being on the call stack does not put `render`'s locals on the callback's chain, since the stack and the chain are unrelated structures. Arrow versus function form changes `this` and `arguments`, not how ordinary names resolve.