Back to JavaScript and TypeScript

Closures and Lexical Scope

A closure is a function that remembers the variables from the scope where it was defined, even after that scope has finished executing. Once you truly understand closures, a huge chunk of JavaScript patterns just clicks.

Fundamentals

How Closures Work

JavaScript resolves variable names at the place the function is written (lexical scope), not where it runs. The function carries that scope reference with it wherever it goes.

A closure captures the surrounding scope

When a function is defined inside another function, it remembers the variables in that outer scope even after the outer function has returned. The inner function plus its captured scope is the closure.

function makeCounter() {
  let count = 0          // lives in makeCounter's scope

  return function increment() {
    count++              // still accessible  closure over count
    return count
  }
}

const counter = makeCounter()
console.log(counter()) // 1
console.log(counter()) // 2
console.log(counter()) // 3

Lexical scope defined by position, not call site

Scope is determined where the function is *written*, not where it is *called*. The inner function looks up variables in the place it was defined, ignoring wherever it gets invoked from.

const greeting = "Hello"

function outer() {
  const name = "World"

  function inner() {
    // inner's lexical scope includes outer's variables
    console.log(`${greeting}, ${name}!`)
  }

  return inner
}

const fn = outer()
fn()  // "Hello, World!"   name is still accessible

Each call creates a new closure

Every time you call the outer function, a fresh scope is created. Each returned function closes over its own independent copy of the variables.

function makeAdder(x) {
  return function(y) { return x + y }
}

const add5  = makeAdder(5)
const add10 = makeAdder(10)

console.log(add5(3))   // 8
console.log(add10(3))  // 13   independent x

Patterns

Practical Patterns

Closures power many everyday JavaScript idioms. These three show up constantly in real codebases.

Private state (module pattern)

Before ES modules, closures were the standard way to hide state. Only the returned API surface is accessible; internal variables are invisible outside.

function createBankAccount(initialBalance) {
  let balance = initialBalance  // private

  return {
    deposit(amount) { balance += amount },
    withdraw(amount) {
      if (amount > balance) throw new Error("Insufficient funds")
      balance -= amount
    },
    getBalance() { return balance },
  }
}

const account = createBankAccount(100)
account.deposit(50)
account.withdraw(30)
console.log(account.getBalance()) // 120
console.log(account.balance)      // undefined  not accessible

Memoisation

A closure can hold a cache object between calls. The memoised wrapper captures cache in its scope and reuses previous results without re-computing.

function memoize(fn) {
  const cache = new Map()       // captured by the wrapper closure

  return function(...args) {
    const key = JSON.stringify(args)
    if (cache.has(key)) return cache.get(key)
    const result = fn(...args)
    cache.set(key, result)
    return result
  }
}

const slowSquare = (n) => { /* expensive */ return n * n }
const fastSquare = memoize(slowSquare)

fastSquare(4)  // computed
fastSquare(4)  // returned from cache

Partial application

Closures make it easy to pre-fill some arguments of a function and return a new, more specific function without a framework or library.

function partial(fn, ...presetArgs) {
  return function(...laterArgs) {
    return fn(...presetArgs, ...laterArgs)
  }
}

function multiply(a, b) { return a * b }

const double = partial(multiply, 2)
const triple = partial(multiply, 3)

console.log(double(5))  // 10
console.log(triple(5))  // 15

Watch Out

Gotchas

Two closure behaviours trip up even experienced developers.

The classic loop + var bug

var is function-scoped, so all loop iterations share the same i variable. By the time the callbacks run, i is already 3. Fix it with let (block-scoped) or an IIFE.

// Bug  all log 3
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0)
}

// Fix 1  use let (each iteration gets its own i)
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0)  // 0, 1, 2
}

// Fix 2  IIFE captures a fresh copy
for (var i = 0; i < 3; i++) {
  ;((j) => setTimeout(() => console.log(j), 0))(i)  // 0, 1, 2
}

Memory retention

Closures keep their captured scope alive as long as the inner function exists. Large objects captured in a long-lived closure will not be garbage collected be deliberate about what you capture.

function createLeak() {
  const bigData = new Array(1_000_000).fill("x")  // 1 MB+

  return function() {
    // bigData is captured  stays in memory as long as this fn lives
    return bigData[0]
  }
}

const fn = createLeak()  // bigData is now kept alive
// fn = null             // uncomment to allow GC