GUIDE · JavaScript
Closures, scope & hoisting
At a glance
- FIRST VISIT
- From Session 3.1, read section 1 (var, let, const and the dead zone) and stop. From Session 3.2, read section 0, the counter, and stop; 3.4 sends you back for sections 1 to 3. Sections 4 to 9 are after Unit 3.
- SCOPE
- var = function-scoped, hoisted as undefined. let/const = block-scoped, hoisted but in the TEMPORAL DEAD ZONE until the declaration line.
- CLOSURE
- a function + the variables it captured by REFERENCE (not by value). Why the classic for-var-setTimeout loop prints 3,3,3.
- HOIST
- function DECLARATIONS are fully hoisted; function EXPRESSIONS and arrows are not (they follow their var/let/const rules).
- SAY
- lexical scopingclosure captures the binding, not the valueIIFEmodule patternmemoizationcurryingpartial applicationdebounce/throttle are closures over a timer
The idea#
Think of variables like labeled boxes in your garage. Scope is which room a box lives in. A var box is function-wide. An if or a for inside that function does not get its own room, so the box leaks out of the block. A let or const box stays in the braces where you wrote it.
Hoisting means the boxes are set on the shelf before your lines run. A var box is already there, empty, so an early read is undefined. A let or const box is on the shelf with the lid taped until its declaration line. Touch it early and you get a ReferenceError. That taped stretch is the temporal dead zone. const means you cannot swap the box for a different box. You can still change a property of an object inside it. k.a = 2 is legal. k = {} throws.
A function declaration is the whole instruction sheet, already pinned up, so you can call it before its line. A function expression or an arrow follows the box rules. With const, the name sits in the dead zone until the assignment.
A closure is a function that keeps its hand on a box after the outer function has gone home. It keeps the box, the binding, not a photocopy of what was inside. Change the contents later and the function sees the new contents. Two calls to makeCounter() get two boxes. They do not share a count.
The famous loop is one shared box. for (var i = 0; i < 3; i++) has a single i. Every function you store closes over that box, and by the time you call them i is 3, so you get 3, 3, 3. for (let j = 0; j < 3; j++) puts a fresh box in the room each trip, so those functions return 0, 1, 2. The old repair was an IIFE: a function that runs immediately, takes the current number as a parameter, and closes over that parameter's own box.
Why it matters#
This is the "why did that print that?" pile. A leaked var collides with another block. A dead-zone let crashes a line that looks like it should see an outer variable of the same name, because the inner box is already shadowing it. The outer box is still there. The inner function is looking at its own box.
Closures are also privacy without a class. The n inside makeCounter has no door except inc, dec, and the getter. once, memoize, currying, partial application, pipe, compose, debounce, and throttle are all functions holding a private box: a flag, a Map, some preset arguments, or a timer. The module pattern is an IIFE that returns only the public methods. The array stays in a box the outside cannot name.
Say lexical scoping, closure captures the binding, IIFE, module pattern, memoization, currying, partial application, and that debounce and throttle are closures over a timer.
A small example#
const shared = [];const fresh = []; for (var i = 0; i < 3; i++) shared.push(() => i);for (let j = 0; j < 3; j++) fresh.push(() => j); const fromVar = shared.map((fn) => fn());const fromLet = fresh.map((fn) => fn());fromVar is [3, 3, 3]. Every function is holding the one i box, and that box ends at 3. fromLet is [0, 1, 2], because each trip got its own j box.
What the code below is doing#
var reads as undefined before its line. A let in the dead zone throws. A const object can change a property and cannot be pointed at a new object. Then var leaks escapes an if, and let stays does not.
The classic loop pushes functions and calls them. The var batch is [3, 3, 3]. The let batch is [0, 1, 2]. The comment shows the pre-let IIFE that captured the current value in a parameter.
decl() works before its line because a function declaration is fully hoisted. expr is a const arrow, so calling it early throws a ReferenceError. After the line it returns 'expression'.
makeCounter closes over n. Two counters move independently. once runs its function a single time and replays the saved result. memoize caches by JSON.stringify of the arguments. The slow square computes twice, for 4 and 5. The memoized fibonacci of 50 is 12586269025.
Currying waits until it has enough arguments, so add3(1)(2)(3) and add3(1, 2)(3) both make 6. partial(Math.max, 10)(3, 7) is 10. Pipe of add-one then double, starting at 3, is 8. Compose of those same functions is 7, because the double runs first.
Debounce and throttle are shown with a fake clock so the file stays synchronous. Three debounce calls clear the previous pending callback, and one fire remains. Throttle calls through when the fake time has moved at least ms since last.
The IIFE store hides items. add and size work. Store.items is undefined.
The trap is the captured binding. read was created while shared was 'a'. After shared = 'b', read() returns 'b', not a snapshot of 'a'.
Inside shadow, a new x hides the outer 'outer'. Outside, x is still 'outer'. globalThis is the global object.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');Start here: one counter, two calls#
// A closure is a function that keeps a variable alive after the function that made it has// returned. Read top to bottom; each assert checks the private number at that exact moment.function makeCounter() { let count = 0; // private: nothing outside can read or change it return function next() { count += 1; return count; }; // `next` is the closure; it holds on to `count`}const tally = makeCounter(); // makeCounter has returned; count is 0 and still aliveconst other = makeCounter(); // a second makeCounter() call makes a second private count// If Session 3.2 was today, stop here. Otherwise read sections 1 to 3 next; they are the scope rules, and 3.4// sends you back for them. Sections 4 to 7 are for after Unit 3, and so are 8 and 9.Verified by 5 asserts
assert.equal(tally(), 1); // call 1: count went 0 -> 1assert.equal(tally(), 2); // call 2: count went 1 -> 2; it rememberedassert.equal(other(), 1); // other's count: 0 -> 1; tally's count is still 2assert.equal(tally(), 3); // tally's count: 2 -> 3; the two counters never shareassert.equal(typeof tally.count, 'undefined'); // no way in from outside; only the function can touch itvar vs let vs const#
// Read top to bottom. Each `const seen...` line records what a name holds at that exact moment; the asserts at the// end of each block check the recorded values, so the order of the lines is the lesson.{ const seenBefore = typeof hoisted; // 'undefined': checked BEFORE the var line below; var is hoisted as undefined var hoisted = 1; // the declaration line; now it holds 1 const seenAfter = hoisted; // 1 let tdzBefore; try { tdzBefore = typeof tdz; } catch (e) { tdzBefore = e.name; } // 'ReferenceError': checked BEFORE the let line below; let exists but is in the TDZ let tdz = 2; // the declaration line ends the dead zone const tdzAfter = tdz; // 2 const k = { a: 1 }; k.a = 2; // const = the binding is constant, the object is not const kAfter = k.a; // 2; `k = {}` would be TypeError: Assignment to constant variable}{ if (true) { var leaks = 'var'; let stays = 'let'; } const afterBlock = [leaks, typeof stays]; // ['var', 'undefined']: var ignored the block, let did not leak}Verified by 6 asserts
assert.equal(seenBefore, 'undefined'); assert.equal(seenAfter, 1); assert.equal(tdzBefore, 'ReferenceError'); assert.equal(tdzAfter, 2); assert.equal(kAfter, 2); assert.deepEqual(afterBlock, ['var', 'undefined']);The classic loop#
const fns1 = [];for (var i = 0; i < 3; i++) fns1.push(() => i); // one shared `i`; the loop leaves it at 3const fns2 = [];for (let j = 0; j < 3; j++) fns2.push(() => j); // fresh `j` per iteration// Pre-let fix: IIFE capturing the value: (function (x) { fns.push(() => x); })(i)Verified by 3 asserts
assert.equal(i, 3); // var leaked out of the loop and it is 3 nowassert.deepEqual(fns1.map((f) => f()), [3, 3, 3]); // every closure reads that same `i`, after the loop endedassert.deepEqual(fns2.map((f) => f()), [0, 1, 2]); // each closure kept its own `j`Function hoisting#
function decl() { return 'declared'; }const expr = () => 'expression';Verified by 3 asserts
assert.equal(decl(), 'declared'); // works before the line: declarations are fully hoistedassert.throws(() => expr(), ReferenceError); // before the line: a const arrow is in the TDZassert.equal(expr(), 'expression'); // after the line: worksClosures: private state, counters, once, memoize (after Unit 3)#
function makeCounterWithApi(start = 0) { let n = start; // private — no way in except these functions return { inc: () => ++n, dec: () => --n, get value() { return n; } };}const c1 = makeCounterWithApi(), c2 = makeCounterWithApi(10);c1.inc(); c1.inc(); c2.dec(); const once = fn => { let done = false, result; return (...a) => (done ? result : (done = true, result = fn(...a))); };let calls = 0;const init = once(() => ++calls);init(); init(); const memoize = fn => { const cache = new Map(); return (...args) => { const key = JSON.stringify(args); if (!cache.has(key)) cache.set(key, fn(...args)); return cache.get(key); };};let slowCalls = 0;const slowSquare = memoize(x => (slowCalls++, x * x));slowSquare(4); slowSquare(4); slowSquare(5);const fib = memoize(n => (n < 2 ? n : fib(n - 1) + fib(n - 2)));Verified by 5 asserts
assert.equal(c1.value, 2);assert.equal(c2.value, 9); // independent closuresassert.equal(calls, 1);assert.equal(slowCalls, 2);assert.equal(fib(50), 12586269025);Currying / partial application / compose / pipe (after Unit 3)#
const curry = fn => function curried(...a) { return a.length >= fn.length ? fn(...a) : (...b) => curried(...a, ...b); };const add3 = curry((a, b, c) => a + b + c);const partial = (fn, ...preset) => (...later) => fn(...preset, ...later);const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);Verified by 5 asserts
assert.equal(add3(1)(2)(3), 6);assert.equal(add3(1, 2)(3), 6);assert.equal(partial(Math.max, 10)(3, 7), 10);assert.equal(pipe(x => x + 1, x => x * 2)(3), 8);assert.equal(compose(x => x + 1, x => x * 2)(3), 7);Debounce / throttle shapes (timer captured in a closure) (after Unit 3)#
function debounce(fn, ms, setT = setTimeout, clearT = clearTimeout) { let t; // the timer id lives in the closure return (...a) => { clearT(t); t = setT(() => fn(...a), ms); };}function throttle(fn, ms, now = Date.now) { let last = -Infinity; // the last accepted time lives in the closure return (...a) => { if (now() - last >= ms) { last = now(); return fn(...a); } };}{ // Fake timer so the file stays synchronous: `pending` holds the one callback the debounce scheduled. let debounced = 0, pending = null; const fakeSet = (cb) => { pending = cb; return 1; }, fakeClear = () => { pending = null; }; const d = debounce(() => debounced++, 100, fakeSet, fakeClear); d(); d(); pending(); // the timer fires once, 100 ms after the LAST call}{ // Fake clock: `clock` is "now" in ms; the throttle lets one call through per 100 ms. let throttled = 0, clock = 0; const th = throttle(() => throttled++, 100, () => clock); th(); th(); clock = 150; th();}Verified by 6 asserts
assert.equal(debounced, 0); // call 1 only scheduled the timer; nothing ran yet assert.equal(debounced, 0); // call 2 cancelled that timer and scheduled a new one assert.equal(debounced, 1); // one run for two calls assert.equal(throttled, 1); // clock 0: the first call runs assert.equal(throttled, 1); // clock still 0: too soon, dropped assert.equal(throttled, 2); // clock 150: 100 ms have passed, runs againIIFE and the module pattern (pre-ESM encapsulation) (after Unit 3)#
const Store = (() => { const items = []; // private return { add: x => items.push(x), size: () => items.length };})();Store.add(1);Verified by 2 asserts
assert.equal(Store.size(), 1);assert.equal(typeof Store.items, 'undefined');Closure gotcha: captured binding changes#
// After Unit 3, like sections 4 to 7.let shared = 'a';const read = () => shared;shared = 'b';Verified by 1 assert
assert.equal(read(), 'b'); // sees the latest value, not a snapshotScope chain / shadowing#
// After Unit 3, like sections 4 to 7.const x = 'outer';function shadow() { const x = 'inner'; return x; }Verified by 3 asserts
assert.equal(shadow(), 'inner');assert.equal(x, 'outer');assert.equal(typeof globalThis, 'object'); // the global object in any environmentSee also