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 nameNested lookup
Whether an inner declaration changes an outer variableBlock shadowing
Whether two callers share stateOne object versus separate factory calls
Why saved callbacks read different loop valuesvar 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:

NameIn inner?In middle?In outer?Selected value
zYesNot searchedNot searched30
yNoYesNot searched20
xNoNoYes10

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.