Lesson 3 of 69

JavaScript Under the Hood

Closures, Scope, and Practical Patterns

Early in my career I spent a full afternoon debugging a loop where every button was showing the same number when clicked. I had five buttons. They all showed "5". I thought the DOM was broken. It was not the DOM. It was a closure over a var loop variable, and I did not understand what that meant. This lesson explains it completely.

What Is a Closure?

A closure is a function that remembers the variables from its lexical environment, even after that environment has returned. The key word is "remembers." The function holds a live reference to those variables, not a copy.

When a function is created, the JavaScript engine attaches the current scope chain to it. This attached scope chain is the closure. The function carries it wherever it goes.

Typescript
function makeGreeter(name: string): () => void {
  // name lives in makeGreeter's scope
  return function greet(): void {
    // greet closes over name: it holds a reference to the outer scope
    console.log(`Hello, ${name}`);
  };
}

const greetAlice = makeGreeter("Alice");
const greetBob = makeGreeter("Bob");

greetAlice(); // Hello, Alice
greetBob(); // Hello, Bob

makeGreeter has already returned when greetAlice() is called. But name is still accessible because greet holds a closure over it. The garbage collector does not collect name as long as greet is alive.

The Scope Chain

Every function has access to its own local scope, plus every outer scope up to the global scope. This forms a chain.

graph diagram: Global Scope\n{ makeCounter, counter }

increment can read count because it walks up the chain to makeCounter's scope. If count is not found there, it keeps walking up to the global scope, then throws a ReferenceError if still not found.

var vs let: The Loop Bug

var is function-scoped. let and const are block-scoped. This distinction is the source of one of the most common JavaScript interview questions.

The classic bug:

Typescript
// Using var: all callbacks close over the same variable
const fnsVar: Array<() => void> = [];

for (var i = 0; i < 3; i++) {
  fnsVar.push(function () {
    console.log(i); // Closes over the var i, not a copy of it
  });
}

fnsVar[0](); // 3
fnsVar[1](); // 3
fnsVar[2](); // 3: all three print 3

Why? var i is hoisted to the enclosing function (or global) scope. There is only one i variable, and after the loop, its value is 3. All three closures point to the same i.

The fix with let:

Typescript
// Using let: each iteration creates a new block scope with its own i
const fnsLet: Array<() => void> = [];

for (let i = 0; i < 3; i++) {
  fnsLet.push(function () {
    console.log(i); // Closes over the block-scoped i for this iteration
  });
}

fnsLet[0](); // 0
fnsLet[1](); // 1
fnsLet[2](); // 2

let creates a new binding for each iteration of the loop. Each closure captures a distinct i.

The old fix (before let) was an IIFE to create a new scope:

Typescript
const fnsIIFE: Array<() => void> = [];

for (var i = 0; i < 3; i++) {
  (function (captured: number) {
    fnsIIFE.push(function () {
      console.log(captured); // captured is a new variable each time
    });
  })(i);
}

Understanding this pattern tells interviewers that you understand both closures and the historical reason for IIFE patterns.

Temporal Dead Zone (TDZ)

let and const declarations are hoisted to the top of their block, but they are not initialised until the declaration line is reached. The period between the start of the block and the declaration is called the temporal dead zone. Accessing the variable in that period throws a ReferenceError.

Typescript
function demonstrate(): void {
  // TDZ for x starts here

  try {
    console.log(x); // ReferenceError: Cannot access 'x' before initialization
  } catch (e) {
    console.log("TDZ caught:", (e as Error).message);
  }

  let x = 10; // TDZ ends here, x is now initialised
  console.log(x); // 10
}

demonstrate();

Compare with var, which hoists and initialises to undefined:

Typescript
function demonstrateVar(): void {
  console.log(y); // undefined: var is hoisted and initialised immediately
  var y = 10;
  console.log(y); // 10
}

TDZ is not a bug. It is a deliberate design decision that prevents you from relying on hoisting, which is a common source of subtle bugs.

Practical Pattern: Counter Factory

The counter factory is the simplest and most common closure example in interviews. It demonstrates private state through closures.

Typescript
function makeCounter(initial = 0): {
  increment: () => number;
  decrement: () => number;
  reset: () => number;
  value: () => number;
} {
  let count = initial; // Private state: not accessible from outside

  return {
    increment: () => ++count,
    decrement: () => --count,
    reset: () => (count = initial),
    value: () => count,
  };
}

const counter = makeCounter(10);
console.log(counter.increment()); // 11
console.log(counter.increment()); // 12
console.log(counter.decrement()); // 11
console.log(counter.reset()); // 10
console.log(counter.value()); // 10

// count is not accessible here: it only exists in makeCounter's scope

The returned object holds methods that close over count. There is no way to set count directly from outside. This is true encapsulation, achieved without a class.

Private State Module Pattern

The module pattern extends the counter idea to bundle related functionality with genuinely private state. Before ES modules existed, this was the standard way to organise code.

Typescript
const userStore = (function () {
  // Private state
  const users: Map<string, { name: string; role: string }> = new Map();
  let nextId = 1;

  // Private helper
  function generateId(): string {
    return `user_${nextId++}`;
  }

  // Public API returned as an object
  return {
    addUser(name: string, role: string): string {
      const id = generateId();
      users.set(id, { name, role });
      return id;
    },

    getUser(id: string): { name: string; role: string } | undefined {
      return users.get(id);
    },

    getUserCount(): number {
      return users.size;
    },
  };
})();

const id1 = userStore.addUser("Alice", "admin");
const id2 = userStore.addUser("Bob", "viewer");

console.log(userStore.getUser(id1)); // { name: 'Alice', role: 'admin' }
console.log(userStore.getUserCount()); // 2

// users and nextId are not accessible here

The IIFE runs immediately and returns the public API. The internal state is trapped in the closure. This is the same concept as a class with private fields, but implemented purely with functions.

Closure Memory Considerations

Closures keep their outer scope alive. This means they can cause memory leaks if you are not careful.

Typescript
function createHeavyProcessor(): () => string {
  // largeData is kept alive as long as process is reachable
  const largeData = new Array(1_000_000).fill("x").join("");

  return function process(): string {
    // Even though we only use largeData.length, the entire string is retained
    return `Processed ${largeData.length} characters`;
  };
}

const process = createHeavyProcessor();
// largeData is still in memory here, even though we only need its length

The fix is to extract what you need before returning:

Typescript
function createEfficientProcessor(): () => string {
  const largeData = new Array(1_000_000).fill("x").join("");
  const length = largeData.length; // Extract what we need

  // largeData can now be garbage collected: the closure only retains length
  return function process(): string {
    return `Processed ${length} characters`;
  };
}

This pattern matters in long-running Node.js services where memory leaks accumulate slowly and cause degraded performance over hours or days.

Memoisation Setup

Closures are the foundation of memoisation: storing previously computed results so you do not compute them again. We will implement the full generic version in the next lesson, but here is the core idea:

Typescript
function memoiseSquare(): (n: number) => number {
  const cache: Record<number, number> = {}; // Private cache via closure

  return function (n: number): number {
    if (n in cache) {
      console.log(`Cache hit for ${n}`);
      return cache[n];
    }
    console.log(`Computing ${n}^2`);
    cache[n] = n * n;
    return cache[n];
  };
}

const square = memoiseSquare();
square(4); // Computing 16
square(4); // Cache hit for 4
square(5); // Computing 25

The cache object lives in memoiseSquare's scope. The returned function closes over it and accumulates results. This is pure closure-based state.

Key insight: A closure is a function plus the scope chain it was created in. That scope chain is kept alive as long as the function is reachable. This is the mechanism behind private state, the loop variable bug, TDZ, and every caching or factory pattern you will see in a TypeScript interview.