Lesson 09 / 17
Closures
The lifetime and sharing of closed-over variables, encapsulation through closures, comparing three constructions of the same kind, the loop-variable trap, and the memoization pattern.
Contents
In the previous lesson’s last example, a counter kept its state not in an object property but in a local variable of the function that produced it. The function returned, but the variable kept living and was visible only to the functions returned from it.
A closure is a function’s ability to keep reaching the variables of the scope it was defined in, even after that scope has ended. The Programming Fundamentals course’s Scope and Lifetime lesson introduced the concept; this lesson places it inside the object model. The result that follows is decisive for the course’s axis: a closure offers a second way of construction that hides state without ever touching the prototype chain.
Encapsulation With a Closure
This time, the measurement record is produced with a factory function. The raw value and calibration offset stay in the function’s parameters; they are never written onto the returned object at all.
function createMeasurementRecord(sensor, rawValue, offset) { let readCount = 0; function readValue() { readCount += 1; return rawValue + offset; } return { sensor, read: readValue, format() { return `${sensor}: ${readValue().toFixed(2)}`; }, check() { return `reads=${readCount}`; }, }; } const record = createMeasurementRecord("S-01", 21.4, -0.4); console.log(record.read().toFixed(2)); console.log(record.format()); console.log(record.check()); console.log(JSON.stringify(record)); console.log(Object.keys(record).join(",")); console.log(record.rawValue); console.log(record.readCount);
21.00
S-01: 21.00
reads=2
{"sensor":"S-01"}
sensor,read,format,check
undefined
undefined
The raw value, the offset, and the read counter appear in no listing operation; they do not enter serialization; they cannot be read or written from outside. This is the same result as class syntax’s private fields, but the mechanism is entirely different: privacy comes not from property descriptors but from scope. A variable is accessible only to the functions that can see it, so the scope boundary itself provides encapsulation.
The check counter reading 2 shows that the format call used the same inner
function too: all three functions share the same scope — and therefore the same
variables.
Three Constructions of the Same Kind
The measurement record used throughout the course can now be built three separate ways. Placing all three side by side makes the trade-off visible in a single output.
function plainRecord(sensor, value) { return { sensor, value, format() { return `${this.sensor}: ${this.value}`; }, }; } function closureRecord(sensor, value) { return { format() { return `${sensor}: ${value}`; }, }; } class ClassRecord { constructor(sensor, value) { this.sensor = sensor; this.value = value; } format() { return `${this.sensor}: ${this.value}`; } } const plainA = plainRecord("S-01", 21.4); const plainB = plainRecord("S-02", 19.8); const closureA = closureRecord("S-01", 21.4); const closureB = closureRecord("S-02", 19.8); const classA = new ClassRecord("S-01", 21.4); const classB = new ClassRecord("S-02", 19.8); console.log([plainA.format(), closureA.format(), classA.format()].join(" | ")); console.log(`plain : shared=${plainA.format === plainB.format}, own=${Object.keys(plainA).join("+")}, json=${JSON.stringify(plainA)}`); console.log(`closure: shared=${closureA.format === closureB.format}, own=${Object.keys(closureA).join("+")}, json=${JSON.stringify(closureA)}`); console.log(`class : shared=${classA.format === classB.format}, own=${Object.keys(classA).join("+")}, json=${JSON.stringify(classA)}`); console.log(Object.getPrototypeOf(plainA) === Object.prototype); console.log(Object.getPrototypeOf(closureA) === Object.prototype); console.log(Object.getPrototypeOf(classA) === ClassRecord.prototype); const brokenClass = classA.format; const brokenClosure = closureA.format; try { brokenClass(); } catch (error) { console.log(`class broken: ${error.constructor.name}`); } console.log(`closure broken: ${brokenClosure()}`);
S-01: 21.4 | S-01: 21.4 | S-01: 21.4
plain : shared=false, own=sensor+value+format, json={"sensor":"S-01","value":21.4}
closure: shared=false, own=format, json={}
class : shared=true, own=sensor+value, json={"sensor":"S-01","value":21.4}
true
true
true
class broken: TypeError
closure broken: S-01: 21.4
The first line confirms all three constructions produce the same behavior. The remaining lines list the differences:
- Sharing. Only in the class construction is the function a single one; in the other two, every object carries its own function. For a thousand records, the class produces one function, the others a thousand.
- Visibility. In the closure construction, since the data is never a property at all, serialization gives an empty object. If the data needs to leave, an explicit method has to be written.
- Chain. Because factory functions return an ordinary object literal, their
prototype is
Object.prototype; none of the paths for type querying, adding shared behavior, or deriving a subtype works. - Binding. In the closure construction, since
thisis never used, the method keeps working even when detached from its object; a class method throws an error in the same situation.
This is where the selection criterion comes from. If many objects, type queries, subtypes, and shared behavior are needed, a class; if a small number of objects, strict privacy, and context-independent calls are needed, a closure. The two are not interchangeable options but two constructions with different cost profiles.
The Lifetime of a Closed-Over Variable
A closure closes over not a value but a variable. Functions sharing the same scope see the same variable; closures produced from different calls have separate scopes.
function createMeasurementCounter(start) { let count = start; return { increment: () => (count += 1), decrement: () => (count -= 1), read: () => count, }; } const first = createMeasurementCounter(0); const second = createMeasurementCounter(100); first.increment(); first.increment(); second.decrement(); console.log(first.read()); console.log(second.read()); function createBuffer() { const values = []; return { add(value) { values.push(value); return values.length; }, average() { if (values.length === 0) return 0; return values.reduce((total, v) => total + v, 0) / values.length; }, size: () => values.length, }; } const buffer = createBuffer(); buffer.add(21.4); buffer.add(19.8); buffer.add(25.1); console.log(buffer.size()); console.log(buffer.average().toFixed(3)); console.log(Object.keys(buffer).join(","));
2 99 3 22.100 add,average,size
The two counters are independent of each other; every createMeasurementCounter call
produces a new scope. In contrast, the three functions returned from the same call
share a single count variable — when one changes it, the other sees the change.
The lifetime rule follows from this: a closed-over variable lives as long as the
last function that can see it remains reachable. After the createBuffer function
returns, there is no outside reference to the values array at all, but because the
three functions can see it, the array stays in memory. This is where a closure’s
memory cost comes from: storing a small function that closes over a large data
structure keeps that entire data structure alive. Memory lifecycle and
reachability-based collection are the subject of the Asynchronous JavaScript and the
Runtime course.
The Loop Variable Trap
The best-known consequence of the rule that a closure closes over a variable shows up in functions produced inside a loop.
const varFns = []; for (var counter = 0; counter < 3; counter += 1) { varFns.push(() => `sensor-${counter}`); } const letFns = []; for (let counter = 0; counter < 3; counter += 1) { letFns.push(() => `sensor-${counter}`); } console.log(varFns.map((f) => f()).join(",")); console.log(letFns.map((f) => f()).join(",")); const manualFns = []; for (var i = 0; i < 3; i += 1) { manualFns.push( (function (fixed) { return () => `sensor-${fixed}`; })(i), ); } console.log(manualFns.map((f) => f()).join(","));
sensor-3,sensor-3,sensor-3 sensor-0,sensor-1,sensor-2 sensor-0,sensor-1,sensor-2
The counter declared with var is function-scoped: there is a single variable
across the whole loop, and all three closures close over it. Because the variable’s
value is 3 when the loop ends, all three functions give the same result. let
produces a new binding on every iteration, so each closure closes over its own
variable.
The third block shows the fix used before block-scoped declarations entered the language: opening a new scope with an immediately invoked function on every iteration and fixing the counter as a parameter. This pattern is the next lesson’s subject.
A State-Carrying Wrapper
A closure’s most productive use is adding state to a function without modifying it. Memoization, introduced in the Programming Fundamentals course’s Recursion lesson, is exactly this: computed results are stored in a dictionary held in the closure.
function memoize(fn) { const cache = Object.create(null); let realCalls = 0; function wrapped(key) { if (key in cache) return cache[key]; realCalls += 1; const result = fn(key); cache[key] = result; return result; } wrapped.realCallCount = () => realCalls; return wrapped; } const openTable = { "S-01": -0.4, "S-02": 0.15, "S-03": 0 }; const safeTable = Object.assign(Object.create(null), openTable); const openRead = memoize((sensor) => openTable[sensor] ?? 0); const safeRead = memoize((sensor) => safeTable[sensor] ?? 0); console.log(openRead("S-01")); console.log(openRead("S-01")); console.log(openRead("S-02")); console.log(openRead("S-01")); console.log(openRead.realCallCount()); console.log(typeof openRead("toString")); console.log(typeof safeRead("toString")); console.log(safeRead("toString")); console.log(safeRead.realCallCount()); console.log(typeof openRead.cache);
-0.4 -0.4 0.15 -0.4 2 function number 0 1 undefined
Only two of the four calls actually run the underlying function; the rest come back
from the cache. The counter is attached to the function as a property, but the counter
itself still sits in the closure — the last line confirms the cache dictionary is
not visible from outside.
The sixth and seventh lines show the cost of the first lesson’s warning. In a
calibration table built with a plain object literal, asking for the "toString" key
finds the function coming from the chain, and the ?? operator does not treat it as
empty. In a table built with Object.create(null) there is no chain, so the correct
result comes back. The same precaution is required in any mapping whose keys come from
outside the program; the type the language sets aside for this job, Map, is covered
in the Advanced Collections topic.
Summary
- A closure is a function’s ability to keep reaching the variables of the scope it was defined in, after that scope has ended.
- The privacy a closure establishes comes not from property descriptors but from the scope boundary; because the data is never a property, it is not listed and not serialized.
- A closure construction is context-independent but shares no behavior and sets up no prototype chain; a class construction is the opposite.
- A closure closes over a variable, not a value; functions produced from the same scope share the same variable, separate calls produce independent scopes.
- A closed-over variable lives as long as the last function that can see it is reachable; this is where memory cost comes from.
- A loop counter declared with
varis a single variable;letproduces a new binding on every iteration.
Next Step
In the loop trap’s old fix, a function was called the instant it was defined, and it was used only to open a new scope. This pattern has a name, and it was also the only way to draw library boundaries before block-scoped declarations. The next lesson covers immediately invoked functions, the scope-isolation job they do, and how far that job has been taken over by the language’s later abilities.
To keep your progress and take notes, Log in
My notes
Log in to take notes.