Lexical Scope
Mental model: Scope is decided by where you typed the code, not by who calls it.
Level: intermediate · about 12 minutes
A scope is a region of code where a name is visible. JavaScript has four kinds: global, module, function and block. The rule that ties them together is short: an inner scope can see outward; an outer scope cannot see inward.
const planet = 'Earth';
function greet() {
const greeting = 'Hello';
console.log(greeting, planet); // reaches outward for `planet`
}
greet();
// console.log(greeting); // ReferenceError: greeting is not definedPredict what happens on the last line before you run it.
Block scope is real, and var ignores it
var — function scoped
function f() {
if (true) {
var x = 'leaks out';
}
console.log(x); // 'leaks out'
}
let / const — block scoped
function f() {
if (true) {
let y = 'stays in';
}
console.log(y); // ReferenceError
}A block is any pair of curly braces: if, for, while, try, or a bare { }. var only cares about function boundaries, which is the single biggest reason it was replaced.
The scope chain
When you use a name, the engine looks in the current scope. Not there? It looks in the enclosing scope. It keeps walking outward until it finds the name or runs out of scopes, at which point you get a ReferenceError.
┌─ global ────────────────────────────────┐
│ const app = 'lab' │
│ ┌─ function outer() ─────────────────┐ │
│ │ const level = 1 │ │
│ │ ┌─ block { } ──────────────────┐ │ │
│ │ │ const level = 2 ← shadows │ │ │
│ │ │ console.log(level, app) │ │ │
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
resolution direction: inner ──► outer
const level = 'global';
function outer() {
const level = 'outer'; // shadows the global
{
const level = 'block'; // shadows outer
console.log(level); // 'block'
}
console.log(level); // 'outer'
}
outer();
console.log(level); // 'global'
Where scope surprises people
let count = 0;
function bump() { count = count + 1; }
bump(); bump();
console.log(count);There is no let inside bump, so count resolves outward to the single shared binding. Both calls mutate the same variable. Had you written let count = count + 1 inside, you would have created a new binding and hit a TDZ error instead.
Try it yourself
Trace the chain
const app = 'JS Mastery Lab';
function outer() {
const tool = 'editor';
function inner() {
const action = 'typing';
console.log(action, tool, app); // three different scopes
}
inner();
}
outer();
Add a fourth nesting level. Then move const tool inside the block and see what breaks.
Exercises
Fix the leaking counter
This function should return the number of even values in an array, but the loop variable leaks and the total is wrong. Rewrite countEven so nothing escapes the block it belongs to, and the count is correct.
Check yourself
- What does this log?
- a ReferenceError —
var ais function-scoped so it survives the block, butlet bdoes not exist outside it. Referencingb— even insidetypeof— throws aReferenceError, becausetypeofdoes not protect you from an out-of-scopelet. - Lexical scope means the scope of a name is determined by…
- where the code is written — Lexical means "from the text". The nesting of your source code fixes the scope chain before anything runs. The call site is irrelevant — that is
this, which follows completely different rules.
Common mistakes
- Assuming
varrespectsifandforblocks. It does not; only functions. - Shadowing a name accidentally and then debugging the wrong variable.
- Believing
typeof xis always safe. For alet/constin its TDZ, it throws.
Takeaways
- Inner scopes see outward; outer scopes never see inward.
- A block is any
{ }.letandconstrespect it,vardoes not. - Name resolution walks outward until it finds the binding or throws
ReferenceError.