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.
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.
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:
// 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:
// 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:
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.
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:
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.
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.
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.
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:
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:
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.