let fixes what var broke;Learn JS Series):Exercise 1 - shortest arrow forms:
const inc = (x) => x + 1;
const mul = (a, b) => a * b;
const hello = () => "hello";
console.log(inc(4), mul(3, 5), hello()); // 5 15 "hello"
The insight: a single-expression body allows implicit return (no braces, no return); zero parameters still need the empty ().
Exercise 2 - returning an object from an arrow:
const makePoint = (x, y) => ({ x, y });
console.log(makePoint(2, 3)); // { x: 2, y: 3 }
// const broken = (x, y) => { x, y }; // returns undefined: {} read as a body
The insight: without the wrapping parentheses, JavaScript reads { } as a function body, so nothing is returned.
Exercise 3 - an arrow callback keeping this:
const stopwatch = {
laps: [],
record() {
[1, 2, 3].forEach((n) => {
this.laps.push(n); // arrow keeps record()'s 'this' (the stopwatch)
});
},
};
stopwatch.record();
console.log(stopwatch.laps); // [1, 2, 3]
The insight: the arrow inherits this from record, so this.laps is the stopwatch's array; a regular-function callback would have lost that this.
Right, with that behind us: the crown jewel. If you understand closures, you understand JavaScript. So much of what looks like magic in real code (React hooks, event handlers that "remember" their setup, module privacy, currying, debouncing) is closures wearing a costume. We are going to take them completely apart, slowly, from the mechanism up.
A closure is a function together with the variables from the scope where it was defined. When you create a function inside another function, the inner function does not just get to use the outer variables while the outer function runs, it keeps access to them even after the outer function has finished and returned. The inner function carries its birthplace's variables with it, like a backpack it never puts down.
This works because of lexical scope (episode 10): what variables a function can see is decided by where it is written, not by where or how it is called. A closure is simply that promise extended through time -- the variables stay reachable as long as the inner function is alive.
function outer() {
const message = "I was captured";
function inner() {
console.log(message); // inner closes over 'message'
}
return inner;
}
const fn = outer(); // outer() has finished and returned
fn(); // "I was captured" - yet 'message' is still alive!
outer has completely finished by the time we call fn. In most people's mental model, its local message should be gone, cleaned up the moment outer returned. But inner closed over it, so it survives. That is the whole magic, and every other pattern in this episode is a consequence of it.
It helps to have a mental model of the machinery, because it makes every later surprise predictable instead of mysterious. When a function runs, JavaScript creates an environment to hold its local variables (episode 10 called this a scope). Normally, when the function returns, that environment is no longer referenced by anything, so the garbage collector is free to throw it away.
A closure changes the accounting. When you define an inner function, that inner function holds a hidden reference back to the environment it was born in. So as long as the inner function is reachable (you returned it, stored it in an array, registered it as an event handler), the environment it captured is also reachable, and therefore cannot be collected. The inner function does not copy the variables. It keeps the whole environment alive and reads the live values out of it.
That distinction -- keeps the environment, does not copy values -- explains almost everything about closures, including the famous bug we will hit shortly. Hold onto it.
Here is the first killer application. A closure lets a function keep state that nothing outside can touch, true privacy, years before JavaScript had private class fields (the #name syntax we will meet in Phase 3). The counter from episode 10, revisited with fresh eyes:
function makeCounter() {
let count = 0; // private: only the returned functions can reach it
return {
increment() { count += 1; return count; },
decrement() { count -= 1; return count; },
value() { return count; },
};
}
const counter = makeCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.decrement()); // 1
console.log(counter.value()); // 1
// there is NO way to reach 'count' directly from out here
count is completely hidden. You cannot read it, you cannot corrupt it, you cannot set it to a nonsense value from outside -- the only doors into it are the three methods, which all close over the same count. This is encapsulation via closure, and it is one of the most important patterns in the language. Libraries lean on it constantly to protect internal state from meddling callers.
Notice something else: every call to makeCounter() makes a brand new count. Two counters do not interfere:
const a = makeCounter();
const b = makeCounter();
a.increment(); a.increment();
b.increment();
console.log(a.value(), b.value()); // 2 1 -- independent private state
Each call to makeCounter created its own environment, so a and b each closed over a separate count. That is exactly the behaviour you want, and you get it for free.
A subtle and important point, straight out of the mechanism above: multiple closures created in the same scope share the same variable. They do not each get a private copy. In the counter, increment, decrement, and value all see and mutate the one count. That shared-live-binding behaviour is exactly what makes the counter work as a unit, and it is also the source of the most famous closure bug in the language.
For years, this snippet baffled newcomers. Using var in a loop and capturing the counter in a closure:
const funcsVar = [];
for (var i = 0; i < 3; i++) {
funcsVar.push(function () { return i; });
}
console.log(funcsVar[0](), funcsVar[1](), funcsVar[2]()); // 3 3 3 (!)
Everyone expects 0 1 2, but you get 3 3 3. Why? Because var i is a single function-scoped variable (episode 2), shared by all three closures. Remember: closures keep the environment, not a snapshot. All three functions closed over the one and only i. By the time you call them, the loop has finished and i has ticked up to 3, so all three read 3. There was only ever one i, and they all point at it.
Now watch let fix it, effortlessly:
const funcsLet = [];
for (let i = 0; i < 3; i++) {
funcsLet.push(function () { return i; });
}
console.log(funcsLet[0](), funcsLet[1](), funcsLet[2]()); // 0 1 2
Because let is block-scoped, each iteration of the loop creates a fresh i in a fresh environment, so each closure captures its own. This is one of the strongest reasons let and const replaced var, and now you understand the mechanism, not just the rule. It is the same closures-share-an-environment behaviour throughout: var gave the closures one binding to share, let gives each iteration a new one.
Before let existed (pre-2015) people fixed this by hand, forcing a fresh scope per iteration with an immediately-invoked function (we will formalize this pattern later in the phase):
const funcsIIFE = [];
for (var i = 0; i < 3; i++) {
(function (captured) {
funcsIIFE.push(function () { return captured; });
})(i); // pass the CURRENT i in as an argument -> its own binding per call
}
console.log(funcsIIFE[0](), funcsIIFE[1](), funcsIIFE[2]()); // 0 1 2
Each call to that wrapper function created a new environment with its own captured parameter, so each inner function closed over a distinct value. It works, but it is noisy. let bakes that fresh-binding-per-iteration behaviour right into the loop, which is why you will almost never write the IIFE version in modern code.
Closures are not academic; you will use them every single day. Here are four shapes that come up constantly.
A once-only function that runs its work a single time, then never again, closes over a boolean flag:
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true;
result = fn(...args);
}
return result;
};
}
const init = once(() => { console.log("initializing"); return 42; });
console.log(init()); // logs "initializing", returns 42
console.log(init()); // returns 42, does NOT log again
Memoization: a wrapper that caches results so an expensive function never recomputes the same answer. The cache lives in the closure, private and persistent between calls:
function memoize(fn) {
const cache = new Map(); // private cache, survives across calls
return function (n) {
if (cache.has(n)) return cache.get(n);
const result = fn(n);
cache.set(n, result);
return result;
};
}
const slowSquare = (n) => { for (let i = 0; i < 1e6; i++) {} return n * n; };
const fastSquare = memoize(slowSquare);
console.log(fastSquare(9)); // computes: 81
console.log(fastSquare(9)); // cached, instant: 81
Configuration capture, so you do not pass the same setting on every call -- a factory that bakes in a choice once:
function makeFormatter(currency) {
return (amount) => `${amount.toFixed(2)} ${currency}`;
}
const euros = makeFormatter("EUR");
const dollars = makeFormatter("USD");
console.log(euros(19.5)); // "19.50 EUR"
console.log(dollars(19.5)); // "19.50 USD"
And event handlers that remember their setup -- the closure carries the context the handler needs, without any global variables:
function makeLogger(prefix) {
let count = 0;
return function (msg) {
count += 1;
return `[${prefix} #${count}] ${msg}`;
};
}
const netLog = makeLogger("net");
console.log(netLog("connected")); // "[net #1] connected"
console.log(netLog("disconnected")); // "[net #2] disconnected"
Each formatter closed over its own currency; each logger over its own prefix and count. These four -- once-only, memoization, configuration capture, and stateful handlers -- appear in real codebases all the time, and they are all just closures wearing different hats.
One honest caveat, since we care about understanding the machine (Phase 7 goes deep on the engine and garbage collection). Because a closure keeps its captured environment alive, the variables in it cannot be garbage-collected while the closure exists. Usually that is exactly what you want -- it is the whole point. But if a long-lived closure captures a large object it no longer needs, that object stays in memory: an accidental memory leak.
The fix is awareness: do not capture more than you need, and let closures go out of scope when you are done with them. Capture the small summary, not the giant source:
function attach() {
const huge = new Array(1_000_000).fill(0); // big
const smallSummary = huge.length; // capture only what you need
return () => smallSummary; // closes over a number, not the whole array
}
console.log(attach()()); // 1000000 - the big array can be collected
By closing over smallSummary (a number) instead of huge (a million-element array), we let the big array be freed as soon as attach returns. This kind of care matters most in long-running programs -- servers, browser tabs open for hours -- where a slow leak accumulates. We will return to it with real profiling tools later; for now, just remember that a closure is a promise to keep something alive, so only promise what you mean to.
Many of you came over from the Learn Python Series (and a few from Rust and Go), so a quick look sideways helps place closures in context. The good news: this is not a JavaScript quirk. Closures are a broadly shared, deeply useful tool, and seeing the same idea elsewhere makes it stick.
Python has closures too, and they behave almost exactly like JavaScript's. A nested function captures the enclosing scope's variables and keeps them alive after the outer function returns:
def make_counter():
count = 0
def increment():
nonlocal count # needed to REASSIGN an enclosed variable
count += 1
return count
return increment
c = make_counter()
print(c(), c(), c()) # 1 2 3
Note the one wrinkle: Python needs the nonlocal keyword to reassign a captured variable (without it, count += 1 would try to create a new local and fail). JavaScript needs no such marker -- count += 1 in a closure just works. Python even has the same loop-capture surprise: a closure created in a for loop captures the loop variable by reference, so all closures see the final value, exactly like the JS var bug. Same mechanism, same footgun.
Rust has closures with a |params| body syntax, and here the difference is instructive. Rust makes capturing explicit and checked by the compiler. By default a closure borrows what it captures; add move and it takes ownership, which is how you keep captured data alive past the enclosing scope:
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |n| n + x // 'move' captures x by value, so it outlives make_adder
}
fn main() {
let add5 = make_adder(5);
println!("{}", add5(10)); // 15
}
Same spirit as our makeFormatter -- bake a value into a returned function -- but Rust forces you to state how the capture happens, and the borrow checker guarantees you never keep a dangling reference. JavaScript keeps it loose and implicit and relies on the garbage collector to clean up. Same idea, different amount of paperwork, which is the recurring theme every time we hold these languages up side by side. ;-)
Go uses function literals that also close over surrounding variables, and, like early JavaScript, Go historically shared the loop variable across iterations (a bug so common the language changed its loop semantics in Go 1.22 to give each iteration a fresh variable, the exact same fix let brought to JS):
package main
import "fmt"
func makeCounter() func() int {
count := 0
return func() int { // closes over 'count'
count++
return count
}
}
func main() {
c := makeCounter()
fmt.Println(c(), c(), c()) // 1 2 3
}
The throughline across all four languages: a function can capture the scope where it was written and carry it around. JavaScript's version is unusually central because so much of the language and its ecosystem is built on passing functions around -- which means closures are firing constantly, whether you notice them or not.
Three exercises, increasing in difficulty. Type them out and run them -- reading is genuinely not the same as knowing. Full solutions open the next episode.
makeBankAccount(initial) that returns an object with deposit, withdraw, and balance methods, keeping the balance completely private in a closure. Prove from outside that you cannot read or set the balance except through the methods.var i in a loop, call them, and observe 3 3 3. Then change one keyword to get 0 1 2, and explain in one sentence exactly which keyword and why.memoize(fn) (as in the episode) and wrap a function that logs "computing..." each time it actually runs. Call the wrapped function several times with a mix of repeated and new arguments, and confirm "computing..." only appears for arguments it has not seen before. Explain which variable in the closure remembers past results.var giving 3 3 3) happens because all closures share one var; let gives each iteration a fresh binding, yielding 0 1 2 (the pre-2015 fix was a per-iteration IIFE).Next episode we build directly on this: higher-order functions -- functions that take or return other functions -- and see how closures are what make them so expressive.