Closures at work: private state, once, memoize, debounce, curry
The closure patterns interviews ask you to write on the spot — a private counter, once, memoize with a sound cache key, debounce and curry.
After this lesson you can
- Keep state private to a function without a class
- Write memoize with a cache key that cannot collide
- Explain what debounce does with a burst of calls, and write it
- Write curry so that a partial application can be reused
Lesson 2 showed what a closure is. This lesson is about using one: each pattern below is a function that returns another function, and the returned function keeps private variables alive between calls.
Private state
function createWallet() {
let balance = 0; // nobody outside can touch this
return {
deposit(n) { balance += n; return balance; },
balance: () => balance,
};
}
There is no property called balance to overwrite: the only way to
change it is through deposit. Every call to createWallet creates a
fresh balance, so two wallets never share one.
Wrappers that count calls: once and limit
A wrapper can keep its own private state about the function it wraps.
once(fn) is the best-known case: the first call runs fn and keeps
the result, and every later call returns that kept result without
running fn again — useful for initialisation that must not run twice.
Record "has it run?" in its own flag, because the first result itself
may be undefined, 0 or "".
The example below uses the same idea for a different rule: limit(fn, max) lets fn run max times and then refuses, with a counter that
only the wrapper can see.
function limit(fn, max) { let calls = 0; return (...args) => { if (calls >= max) throw new Error(`limit of ${max} reached`); calls++; return fn(...args); };} function createWallet() { let balance = 0; return { deposit(n) { balance += n; return balance; }, balance: () => balance, };} export function run() { const sendCode = limit((phone) => `code sent to ${phone}`, 2); const sends = ["555-01", "555-02", "555-03"].map((phone) => { try { return sendCode(phone); } catch (e) { return e.message; } }); const a = createWallet(); const b = createWallet(); a.deposit(10); a.deposit(5); b.deposit(1); return { sends, a: a.balance(), b: b.balance(), field: a.balance === 15 };}memoize
memoize(fn) caches results by argument, so a repeated call skips the
work. The function must be pure — same arguments, same result, no
side effects — or the cache returns stale answers. Two details decide
whether a memoize is correct:
- The cache key must identify the arguments exactly. Joining them
into a string with no separator makes
(1, 12)and(11, 2)both"112".JSON.stringify(args)gives"[1,12]"and"[11,2]". - Check whether the key is present, not whether the value is
truthy.
if (cache[key])recomputes every result that is0,""orfalse. AMapwithhasavoids both that and the inherited-key problems of a plain object (lesson 15).
An unbounded cache also grows forever; real ones cap their size or expire entries. Both key problems, checked:
export function run() { const cache = {}; cache[[1, 12].join("")] = 13; // key "112" const joinedCollide = [11, 2].join("") in cache; const jsonA = JSON.stringify([1, 12]); const jsonB = JSON.stringify([11, 2]); const results = new Map([["0,0", 0]]); const truthyCheckSaysCached = Boolean(results.get("0,0")); const hasSaysCached = results.has("0,0"); return { joinedCollide, jsonA, jsonB, truthyCheckSaysCached, hasSaysCached };}debounce
debounce(fn, ms) delays fn until calls have stopped for ms
milliseconds; each new call cancels the pending timer and starts a new
one. A burst of keystrokes in a search box becomes one request with the
final text. (Throttle is the sibling: at most one call per interval,
spread through the burst.)
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
function debounce(fn, ms) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };} const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export async function run() { const searches = []; const search = debounce((text) => searches.push(text), 30); for (const text of ["j", "ja", "jav", "java", "javas"]) search(text); await wait(100); search("python"); await wait(100); return searches;}curry
curry(fn) turns f(a, b, c) into a function that also accepts its
arguments in instalments: f(a)(b)(c), f(a, b)(c). It is a closure
over "the arguments collected so far" that compares their count with
fn.length, the number of declared parameters. The trap is storing
the collected arguments in one shared array: a partial application such
as const double = curriedMultiply(2) must be reusable, so each call
has to build a new list instead of pushing onto the old one. That is
the exercise.
Common mistakes
- A cache key built by concatenation.
`${a}${b}`collides for(1, 12)and(11, 2)and returns the wrong cached answer. if (cache[key])instead ofcache.has(key). Falsy results are recomputed every time, so the memoised function is not memoised for them.- Keeping state at module level instead of inside the factory. Every counter or cache created then shares one variable.
- Debounce that does not clear the previous timer. Every call in the burst still fires, just later.
What interviewers ask
- Implement
memoize. What are its limits? — A keyed by serialised arguments, checked with ; only safe for pure functions, grows without bound, and object arguments need a stable key.
Exercise · Pro
curry, with partial applications you can reuse
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade