bastianplsfix

Closures

A closure is a function together with a live connection to the variables where it was created. When the function later reads a name it did not declare itself, that connection is what supplies the value, even if the code that created it has already finished.

Every function in JavaScript is a closure. The word does not name a feature you switch on; it names how functions have always worked, and having a name for it lets you reason about the consequences. This page earns the mechanism first, then its consequences: the useful ones, and the most famous bug in the language.

Create programs/closures.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import { assertEquals } from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

a function carries its birth scope

A name mentioned inside a function is one of two things. Bound names are the ones the function declares: its parameters and its own locals. Free names come from outside, and a closure is what makes them work. In the returned function below, name is bound and greeting is free:

Deno.test("a function carries its birth scope", () => {
function greeterFor(greeting: string) {
return (name: string) => `${greeting}, ${name}`;
}

const hello = greeterFor("Hello");
const goodbye = greeterFor("Goodbye");

assertEquals(hello("ada"), "Hello, ada");
assertEquals(goodbye("ada"), "Goodbye, ada");
});
Check programs/closures.test.ts
running 1 test from ./programs/closures.test.ts
a function carries its birth scope ... ok (0ms)

ok | 1 passed | 0 failed (1ms)

Walk the timeline, because the timing is the claim.

  1. greeterFor("Hello") runs and returns. By the rules of the scope and declarations page, its parameter greeting belongs to that finished call, and should be long gone.
  2. hello("ada") runs later, reads the free name greeting, and gets "Hello". The connection to the birth scope kept the parameter alive past its function's return.
  3. goodbye answers "Goodbye, ada", so the two factory calls produced two functions with two separate connections, one per call.

This is the whole mechanism. Everything below is a consequence of it.

it is a connection, not a copy

The distinction that matters most, and the one people get wrong. A function is created while count holds 0, and count changes afterward. Predict what the function reads, then save:

Deno.test("it is a connection, not a copy", () => {
let count = 0;
const read = () => count;

count = 5;

assertEquals(read(), 0);
});
Check programs/closures.test.ts
running 2 tests from ./programs/closures.test.ts
a function carries its birth scope ... ok (0ms)
it is a connection, not a copy ... FAILED (9ms)

ERRORS

it is a connection, not a copy => ./programs/closures.test.ts:16:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- 5
+ 0

FAILURES

it is a connection, not a copy => ./programs/closures.test.ts:16:11

FAILED | 1 passed | 1 failed (10ms)

error: Test failed

read() is 5. The function did not capture the value 0 at the moment it was created. It captured the variable count itself, and it reads whatever is in there at the moment it runs. Correct the prediction to 5:

it is a connection, not a copy ... ok (0ms)

Hold this one firmly: a closure reads variables, not values, so what it sees depends on when it runs. Get it backwards and both the useful patterns below and the classic bug will look arbitrary.

state that survives between calls

If a closure can read an outer variable, it can also write to one, which turns the birth scope into private storage that persists across calls:

Deno.test("state that survives between calls", () => {
function createInc(startValue: number) {
let index = -1;
return (step: number): [number, number] => {
startValue += step;
index++;
return [index, startValue];
};
}

const inc = createInc(5);
assertEquals(inc(2), [0, 7]);
assertEquals(inc(2), [1, 9]);
assertEquals(inc(2), [2, 11]);

const first = createInc(0);
const second = createInc(100);
first(1);
assertEquals(first(1), [1, 2]);
assertEquals(second(1), [0, 101]);
});
state that survives between calls ... ok (0ms)

Follow the three calls, then the two factories.

  1. Each call to inc adds to startValue and bumps index, and the next call sees the results of the last one: [0, 7], [1, 9], [2, 11]. The parameter and the local are doing the job that fields on an object would do elsewhere, held by the connection instead of by a this.
  2. first and second come from separate factory calls, so they hold separate storage. first has been called twice and reports [1, 2]; second reports [0, 101], untouched by anything first did. One birth scope per factory call, exactly as the first step showed with hello and goodbye.

privacy that needs no keyword

Return two functions from the same scope and they share one variable that nothing else can reach:

Deno.test("privacy that needs no keyword", () => {
function createCounter() {
let count = 0;
return {
increment: () => {
count += 1;
return count;
},
read: () => count,
};
}

const counter = createCounter();
counter.increment();
counter.increment();

assertEquals(counter.read(), 2);
assertEquals(Object.keys(counter), ["increment", "read"]);
});
privacy that needs no keyword ... ok (0ms)

Two facts, one from each assertion.

  1. read() reports 2 after two increment() calls, so both functions reach the same count: one shared connection into the scope they were born in.
  2. Object.keys(counter) lists increment and read, and nothing else. count is not a property of the returned object; it exists only in the factory's scope, and the only references to it are the two functions born beside it.

That makes count genuinely private. There is no property to reach it through and no convention asking you not to. This predates class private fields by two decades and is still a clean way to build a small stateful thing when a class feels heavy.

the loop bug

Here is the most famous consequence of "a connection, not a copy". Three closures made in a var loop, three made in a let loop. Predict what the var set reports, then save:

Deno.test("the loop bug", () => {
const withVar: (() => number)[] = [];
// deno-lint-ignore no-var no-inner-declarations
for (var i = 0; i < 3; i++) withVar.push(() => i);

const withLet: (() => number)[] = [];
for (let j = 0; j < 3; j++) withLet.push(() => j);

assertEquals(withVar.map((read) => read()), [0, 1, 2]);
assertEquals(withLet.map((read) => read()), [0, 1, 2]);
});
Check programs/closures.test.ts
running 5 tests from ./programs/closures.test.ts
...
the loop bug ... FAILED (9ms)

ERRORS

the loop bug => ./programs/closures.test.ts:67:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
- 3,
- 3,
- 3,
+ 0,
+ 1,
+ 2,
]

FAILURES

the loop bug => ./programs/closures.test.ts:67:11

FAILED | 4 passed | 1 failed (10ms)

error: Test failed

All three var closures report 3. The chain, one link at a time.

  1. var is scoped to the whole surrounding function, from the scope page's var scopes to the function and starts as undefined, so the loop has exactly one i for all three turns.
  2. Each () => i is a connection to a variable, not a copy of a value, so all three closures point at that one i.
  3. By the time anyone calls a closure, the loop has finished and left i at 3, and all three connections read the same variable at the same moment.

let fixes this with a special rule made for loops: a fresh binding for each turn, so there are three separate j variables and each closure is connected to a different one. The rule exists precisely because the var behavior was such a reliable source of bugs. Correct the var prediction to [3, 3, 3]:

the loop bug ... ok (0ms)

The two lint signatures on the var line measure how far Deno keeps you from writing this by accident. You will meet it in older code and in interview questions, and now the diff is one you have already read.

for...of gets the same per-iteration binding

The modern loop earns the same protection:

Deno.test("for...of gets the same per-iteration binding", () => {
const readers: (() => string)[] = [];
for (const item of ["a", "b", "c"]) readers.push(() => item);

assertEquals(readers.map((read) => read()), ["a", "b", "c"]);
});
for...of gets the same per-iteration binding ... ok (0ms)

Each turn of a for...of declares a fresh item, so the three closures hold three separate connections and each reports its own element. This is why the modern loop with const is safe to close over without thinking about it, and one more reason the scope page's advice was const by default.

What a closure keeps alive

A closure holds its birth scope, and a scope is kept as a whole rather than trimmed to the names actually used. A callback that reads one small field can therefore keep a large object reachable for as long as the callback exists. That matters for a listener you registered and never removed, or a cache of functions built inside a loop over something large. When you are chasing memory that will not go away, look for a long-lived function that was born somewhere expensive.

Closures and the type checker

TypeScript follows closures when narrowing, and knows when to stop. Both halves were measured on the unions and narrowing page: narrowing established before a closure is created still applies inside it, and narrowing ends where the value can change showed a reassignment of the captured variable invalidating it, with the checker predicting the exact ReferenceError-shaped crash that this page's model explains. The checker, in other words, has read this entry: it knows a closure is a connection, not a copy.

In practice