Lexical Scope
Which binding does a name refer to? Start where the function was defined, then look outward through its surrounding scopes. The local variables of the code that calls it do not replace those surroundings.
Use this page to find a particular lookup or state-sharing example. You do not need to work through every recipe. Basic JavaScript functions, calls and variables are enough for the first examples.
Core Concepts
For a bare identifier such as count, look for the nearest binding with that name. If it is in a surrounding scope, the function can use that binding. A nearer binding hides an outer one, even when the nearer binding has not been initialized yet.
That lookup is different from reading object.count. Property access starts with the object. Likewise, changing a call's this value does not change where a bare identifier is resolved.
| If you need to check... | Use the example about... |
|---|---|
| Which surrounding function supplies a name | Nested lookup |
| Whether an inner declaration changes an outer variable | Block shadowing |
| Whether two callers share state | One object versus separate factory calls |
| Why saved callbacks read different loop values | var and let loop bindings |
Running the examples: each JavaScript pane is a complete, independent Node.js 24 ES module. Save one pane as example.mjs and run node example.mjs. Use a fresh process for each pane. The one intentional-error example has its own instructions.
Top-level declarations in these files belong to their module. Do not treat a module binding as an automatically created property on a browser's window or Node's global object.
Implementation and Best Practices
Nested lookup
inner has its own z. It reads y from middle and x from outer.
function outer() {
const x = 10
function middle() {
const y = 20
function inner() {
const z = 30
console.log(x + y + z) // 60
}
inner()
}
middle()
}
outer()
For the call to inner, the lookup is:
| Name | In inner? | In middle? | In outer? | Selected value |
|---|---|---|---|---|
z | Yes | Not searched | Not searched | 30 |
y | No | Yes | Not searched | 20 |
x | No | No | Yes | 10 |
This is a logical lookup trace, not a picture of physical stack or heap storage. Each call to outer creates its own local bindings. Calling it from a function with another x does not substitute that caller's x.
Block shadowing is allowed
These two declarations are in different scopes. The inner x does not reassign the outer x. Another nested block could introduce another distinct x too.
const x = 10
{
const x = 20
console.log(x) // 20
}
console.log(x) // 10
Redeclaring a const in the same scope is different. The following pane is deliberately invalid. Save it separately as duplicate-binding.mjs. Node reports a SyntaxError before executing the module, so even the first log does not run.
console.log('This line does not run')
const value = 1
const value = 2
console.log(value)
This is a whole-file syntax failure, not a successful first statement followed by a runtime error.
One object versus separate factory calls
An immediately invoked function can create one shared object. Calling a factory twice creates two sets of local bindings instead. The difference is how many times the state-creating function runs, not whether its returned methods look similar.
const sharedCounter = (function () {
let count = 0
return {
increment() {
return ++count
},
getCount() {
return count
},
}
})()
const firstUser = sharedCounter
const secondUser = sharedCounter
firstUser.increment()
console.log(secondUser.getCount()) // 1
secondUser.increment()
console.log(firstUser.getCount()) // 2
function createCounter() {
let count = 0
return {
increment() {
return ++count
},
getCount() {
return count
},
}
}
const firstCounter = createCounter()
const secondCounter = createCounter()
firstCounter.increment()
console.log(firstCounter.getCount(), secondCounter.getCount()) // 1 0
firstUser and secondUser refer to the same object whose methods share one count. The factory results are different objects whose methods refer to different count bindings. These tiny counters illustrate binding ownership, not validation or behavior beyond JavaScript's safe integer range.
Saved callbacks reveal loop bindings
A callback can be created now and called after the loop finishes. Timers are not needed to see the binding difference.
The var callbacks below all read the same i, which is 3 after their loop. The let loop creates a separate binding for each iteration, so its callbacks read 0, 1 and 2.
const varReaders = []
for (var i = 0; i < 3; i++) {
varReaders.push(() => i)
}
const letReaders = []
for (let i = 0; i < 3; i++) {
letReaders.push(() => i)
}
console.log(varReaders.map((read) => read()).join(',')) // 3,3,3
console.log(letReaders.map((read) => read()).join(',')) // 0,1,2
The block-scoped i in the second loop does not replace the module's var binding. There is no rule that closures always preserve the value from the moment the callback was created. Each callback reads its particular binding when it runs.
Optional: the same contrast with timers
Use this version if your question involves callbacks running later. It adds promises and top-level await: readLater resolves with a callback's result, and Promise.all collects those results in the order the promises were supplied. The supplied callbacks only read loop variables and do not throw.
The returned values array is initially empty. Awaiting done waits for the actual completion instead of assuming that a chosen number of milliseconds was enough.
function readLater(read) {
return new Promise((resolve) => {
setTimeout(() => resolve(read()), 0)
})
}
function collectLoopValues() {
const values = []
const pending = []
for (var i = 0; i < 3; i++) {
pending.push(readLater(() => i))
}
for (let i = 0; i < 3; i++) {
pending.push(readLater(() => i))
}
const done = Promise.all(pending).then((results) => {
values.push(...results)
})
return { values, done }
}
const { values, done } = collectLoopValues()
console.log(JSON.stringify(values)) // []
await done
console.log(JSON.stringify(values)) // [3,3,3,0,1,2]
The zero-delay timers do not run in the middle of these synchronous loops. Their exact execution time is not promised. The ordered result above comes from Promise.all, not a claim that timer callbacks must always finish in a particular order.
When another topic owns the question
If the question is about fn.call(receiver) or calling a function with new, use the this & Binding lesson for the receiver rules and Function Types for the relevant function forms. Supplying a receiver is not a simulation of dynamic identifier lookup.
For imports, exports and module organization, use Modules. A registry stored in a class property is not private merely because a TypeScript declaration calls that property private. The counter examples here use local bindings instead.
These are targeted next topics, not prerequisites for the lookup examples. Choose the reference case you need, explain which binding it uses and stop there.