Chapter 11 — The Polyfill Set
Every function they ask you to rebuild, the reference answer, and the edge case they are waiting for
Prerequisites: Chapter 03 — Promises in Real Code (the full MyPromise), Chapter 07 — Controlling Concurrency (debounce, throttle, pools). this, prototypes and closures are covered in FE-10. Practice: the gym, exercise 10-polyfill-kata.
How to Read This Chapter
This is a reference chapter. Each entry has four parts: the spec in a few lines, a reference implementation, the edge cases interviewers probe, and a one-line test you can say or type to prove it works. Every implementation here was run under node --test in Node 22 against the built-in where one exists (myFlat against Array.prototype.flat on a few hundred random nested arrays, myBind under new against native bind, the emitter against node:events, and so on).
In the interview, do not start typing. Say the spec back first, including the edge cases you plan to handle, then write it. Most lost points come from a case the candidate never mentioned, not from a syntax slip. When you finish, run your own one-line test out loud.
| Term | Meaning |
|---|---|
| Hole | A missing index in a sparse array: [1, , 3] has no key 1. 1 in arr is false. Most array methods skip holes |
| Array-like | An object with a length and numeric keys (arguments, a NodeList). Array methods work on them via .call |
new.target | Inside a function, the constructor new was applied to, or undefined for a plain call |
| Thenable | Any object with a then method. Promise.resolve(x) adopts its state |
| SameValueZero | Equality used by Map, Set, includes: like === but NaN equals NaN |
| Single-flight | Concurrent callers for the same key share one in-flight call instead of each starting one |
| Drift | A repeating timer slipping later on each tick because each delay is measured from "now" |
| Jitter | A random part added to a retry delay so many clients do not retry at the same moment |
Part 1: Functions and this
Function.prototype.call and apply
Spec. fn.call(thisArg, ...args) runs fn once with this set to thisArg. apply takes the arguments as an array or array-like. The trick: a function called as obj.method() gets this === obj, so attach fn to the object for one call.
Function.prototype.myCall = function (thisArg, ...args) {
if (typeof this !== 'function') throw new TypeError('myCall must be called on a function');
// Sloppy-mode rule: null/undefined become globalThis, primitives get boxed.
const ctx = thisArg == null ? globalThis : Object(thisArg);
const key = Symbol('fn'); // a Symbol cannot clash with the object's own keys
ctx[key] = this;
try {
return ctx[key](...args); // called as a method, so `this` is ctx
} finally {
delete ctx[key];
}
};
Function.prototype.myApply = function (thisArg, argsArray) {
if (typeof this !== 'function') throw new TypeError('myApply must be called on a function');
if (argsArray != null && typeof argsArray !== 'object') {
throw new TypeError('CreateListFromArrayLike called on non-object');
}
const args = argsArray == null ? [] : Array.from(argsArray); // array-likes allowed
return this.myCall(thisArg, ...args);
};
Edge cases they probe:
- Why a
Symbolkey? A string key like'fn'could overwrite a real property on the object. deleteinfinally: the temporary key must go even whenfnthrows.null/undefinedbecomeglobalThisand primitives are boxed. That is sloppy-mode behaviour. A strict-mode function called with nativecall(null)seesthis === null; this polyfill cannot reproduce that withoutReflect.apply, which defeats the exercise. Say so.- A frozen object cannot take the temporary key, so
myCallthrows on it. Nativecalldoes not. apply(ctx, 5)throwsTypeError;apply(ctx)andapply(ctx, null)mean no arguments.
Test: Math.max.myApply(null, [1, 5, 2]) === 5
Function.prototype.bind, including new
Spec. Return a new function that calls the target with a fixed this and some arguments prepended. If the bound function is called with new, the fixed this is ignored and the target is constructed with the combined arguments.
Function.prototype.myBind = function (thisArg, ...preset) {
const target = this;
if (typeof target !== 'function') throw new TypeError('Bind must be called on a function');
function bound(...later) {
const args = [...preset, ...later];
if (new.target) {
// Called with `new`: thisArg is ignored and the target is constructed.
// If new.target is `bound` itself, construct as if `new target(...)`.
return Reflect.construct(target, args, new.target === bound ? target : new.target);
}
return target.apply(thisArg, args);
}
// Lets `obj instanceof bound` work. Native bound functions get this from the target.
if (target.prototype) bound.prototype = target.prototype;
Object.defineProperty(bound, 'name', { value: `bound ${target.name}` });
Object.defineProperty(bound, 'length', { value: Math.max(0, target.length - preset.length) });
return bound;
};
Edge cases they probe:
newon the bound function.new.targetis set only fornewcalls.Reflect.constructalso handlesclasstargets andclass Sub extends Bound, which the old ES5 answer cannot.- The ES5 answer is
const isNew = this instanceof bound; target.apply(isNew ? this : thisArg, args)withbound.prototype = Object.create(target.prototype). Know it, and know its two flaws: it cannot construct aclass, and it misfires if the bound function is called as a method of an object that happens to inherit from it. - Binding twice does not rebind:
f.bind(a).bind(b)still usesa, because the outer bound function calls the inner one, which ignores itsthis. lengthis the target's length minus the preset count, never negative;nameis"bound f".- Arrow functions ignore
thisArgentirely, bound or not.
Test: function P(x, y) { this.x = x; this.y = y }; const p = new (P.myBind({}, 1))(2); p.x === 1 && p instanceof P
Part 2: Arrays
All of these read length once, skip holes with i in O, and pass (value, index, array) to the callback. Those three details are what the interviewer checks.
map and filter
Spec. map returns an array of the same length with each present element transformed; holes stay holes. filter returns a dense array of the elements whose callback returned truthy.
Array.prototype.myMap = function (callback, thisArg) {
if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`);
const O = Object(this);
const len = O.length >>> 0; // read once: elements pushed during the loop are not visited
const out = new Array(len);
for (let i = 0; i < len; i++) {
if (i in O) out[i] = callback.call(thisArg, O[i], i, O); // holes stay holes
}
return out;
};
Array.prototype.myFilter = function (callback, thisArg) {
if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`);
const O = Object(this);
const len = O.length >>> 0;
const out = [];
for (let i = 0; i < len; i++) {
if (!(i in O)) continue; // holes are skipped
const value = O[i]; // read before the callback, which may mutate the array
if (callback.call(thisArg, value, i, O)) out.push(value);
}
return out;
};
Edge cases they probe:
[1, , 3].map(f)keeps the hole and never callsffor index 1.new Array(len)plusi in Odoes that;out.pushwould not.- Elements pushed during the loop are not visited, because
lenwas read first. thisArgis used for the callback (and ignored by arrow callbacks).- A non-function callback throws
TypeErrorbefore any iteration. - Works on array-likes:
Array.prototype.myMap.call({ length: 2, 0: 'a', 1: 'b' }, f).
Test: [1, , 3].myMap(x => x * 2) has length 3 and !(1 in result)
reduce, without an initial value
Spec. Fold the array left to right. With no initial value, the first present element is the seed and iteration starts after it. An empty array (or all holes) with no initial value throws TypeError.
Array.prototype.myReduce = function (callback, ...rest) {
if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`);
const O = Object(this);
const len = O.length >>> 0;
let i = 0;
let acc;
if (rest.length > 0) {
acc = rest[0]; // an explicit `undefined` initial value still counts
} else {
while (i < len && !(i in O)) i++; // the first *present* element is the seed
if (i >= len) throw new TypeError('Reduce of empty array with no initial value');
acc = O[i++];
}
for (; i < len; i++) {
if (i in O) acc = callback(acc, O[i], i, O);
}
return acc;
};
Edge cases they probe:
- "No initial value" means the argument was not passed, not that it is
undefined.[].reduce(f, undefined)returnsundefined;[].reduce(f)throws. Hence...restandrest.length, notinit === undefined. [, 2, , 4].reduce(add)seeds with2(index 1), not with a hole.- The callback gets
(acc, value, index, array); the seed's index is never passed.
Test: [, 2, , 4].myReduce((a, b) => a + b) === 6
flat(depth) and flatMap
Spec. flat(depth = 1) returns a new array with nested arrays spread in, up to depth levels. Holes are dropped at every level. flatMap(f) is map(f) then flat(1), in one pass.
Array.prototype.myFlat = function (depth = 1) {
const d = Math.trunc(Number(depth)) || 0; // NaN and null become 0; Infinity stays
const out = [];
const walk = (arr, level) => {
for (let i = 0; i < arr.length; i++) {
if (!(i in arr)) continue; // holes are removed at every level
const v = arr[i];
if (Array.isArray(v) && level > 0) walk(v, level - 1);
else out.push(v);
}
};
walk(Object(this), d);
return out;
};
Array.prototype.myFlatMap = function (callback, thisArg) {
if (typeof callback !== 'function') throw new TypeError(`${callback} is not a function`);
const O = Object(this);
const len = O.length >>> 0;
const out = [];
for (let i = 0; i < len; i++) {
if (!(i in O)) continue;
const v = callback.call(thisArg, O[i], i, O);
if (Array.isArray(v)) {
for (let j = 0; j < v.length; j++) if (j in v) out.push(v[j]); // one level only
} else {
out.push(v);
}
}
return out;
};
Edge cases they probe:
depthcoercion:undefined→ 1;0, negative,NaN,null→ no flattening (holes are still removed);Infinityflattens fully;'2'→ 2;1.7→ 1.- Only real arrays are spread (
Array.isArray), not array-likes or strings. - Recursion depth. For a 100 000-deep array, use an explicit stack: push
[array, index, level]frames and loop. Mention it; write the recursive one unless asked. flatMapflattens exactly one level:[1].flatMap(x => [[x]])is[[1]].
Test: [1, [2, [3, [4]]]].myFlat(Infinity) deep-equals [1, 2, 3, 4]
Part 3: Objects and Prototypes
Object.create, instanceof, new
Spec. Object.create(proto) makes an empty object whose prototype is proto. a instanceof C walks a's prototype chain looking for C.prototype. new C(...args) creates an object linked to C.prototype, runs C with it as this, and returns it unless C returned an object.
function objectCreate(proto, properties) {
if (proto !== null && typeof proto !== 'object' && typeof proto !== 'function') {
throw new TypeError('Object prototype may only be an Object or null');
}
let obj;
if (proto === null) {
obj = {};
Object.setPrototypeOf(obj, null); // the old ES5 shim could not do this
} else {
function F() {}
F.prototype = proto;
obj = new F();
}
if (properties !== undefined) Object.defineProperties(obj, properties);
return obj;
}
function myInstanceOf(obj, Ctor) {
if (typeof Ctor !== 'function') throw new TypeError("Right-hand side of 'instanceof' is not callable");
if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) return false;
const target = Ctor.prototype;
if (target === null || (typeof target !== 'object' && typeof target !== 'function')) {
throw new TypeError("Function has non-object prototype in instanceof check");
}
let p = Object.getPrototypeOf(obj);
while (p !== null) {
if (p === target) return true;
p = Object.getPrototypeOf(p);
}
return false;
}
function myNew(Ctor, ...args) {
if (typeof Ctor !== 'function') throw new TypeError(`${Ctor} is not a constructor`);
const proto = Ctor.prototype;
// 1. A fresh object linked to Ctor.prototype (Object.prototype if that is not an object).
const obj = Object.create(proto !== null && typeof proto === 'object' ? proto : Object.prototype);
// 2. Run the constructor with `this` = the new object.
const result = Ctor.apply(obj, args);
// 3. An object returned by the constructor wins; a primitive return is ignored.
return result !== null && (typeof result === 'object' || typeof result === 'function') ? result : obj;
}
Edge cases they probe:
Object.create(null): the ES5 trick (F.prototype = proto; new F()) silently gives youObject.prototypewhenprotoisnull, becausenewfalls back whenF.prototypeis not an object. That is why thenullbranch exists.instanceofwith a primitive on the left returnsfalse(no throw); with a non-callable on the right it throws; with an arrow function on the right (noprototype) it throws.- Native
instanceofchecksC[Symbol.hasInstance]first. That is howclass Even { static [Symbol.hasInstance](n) { return n % 2 === 0 } }makes2 instanceof Eventrue. Mention it; the walk above is what they want. instanceoffails across realms: an array from an iframe is notinstanceof Arrayin the parent. That is whyArray.isArrayexists.myNewwith a constructor that returns a primitive ignores it; returning an object (or a function) replacesthis.myNewcannot construct aclass(applyon a class throws "cannot be invoked without 'new'") and does not reject arrow functions the way nativenewdoes. Real code usesReflect.construct.
Test: function A() {}; myInstanceOf(myNew(A), A) && !myInstanceOf(1, Number) && Object.getPrototypeOf(objectCreate(null)) === null
Deep clone
Spec. Return a copy that shares no mutable object with the input. Keep cycles and shared references as cycles and shared references. Copy Date, RegExp, Map, Set; keep prototypes.
function deepClone(value, seen = new WeakMap()) {
if (value === null || typeof value !== 'object') return value; // primitives, and functions by reference
if (seen.has(value)) return seen.get(value); // cycles and shared references
if (value instanceof Date) return new Date(value.getTime());
if (value instanceof RegExp) {
const r = new RegExp(value.source, value.flags);
r.lastIndex = value.lastIndex;
return r;
}
if (value instanceof Map) {
const m = new Map();
seen.set(value, m); // register before recursing, or a cycle recurses forever
value.forEach((v, k) => m.set(deepClone(k, seen), deepClone(v, seen)));
return m;
}
if (value instanceof Set) {
const s = new Set();
seen.set(value, s);
value.forEach((v) => s.add(deepClone(v, seen)));
return s;
}
const out = Array.isArray(value) ? new Array(value.length) : Object.create(Object.getPrototypeOf(value));
seen.set(value, out);
for (const key of Reflect.ownKeys(value)) { // string and symbol keys
if (Object.getOwnPropertyDescriptor(value, key).enumerable) {
out[key] = deepClone(value[key], seen); // getters are read, not copied
}
}
return out;
}
Edge cases they probe:
- Cycles. The
WeakMapmaps original → copy. It must be filled before recursing into children, ora.self = arecurses forever. - Shared references:
{ a: x, b: x }should give a copy wherecopy.a === copy.b. The same map does this. JSON.parse(JSON.stringify(x))losesDate(becomes a string),Map/Set(become{}),undefinedand functions (dropped),NaN(becomesnull), and throws on cycles.- Not handled here: typed arrays,
ArrayBuffer,Error, DOM nodes, private class fields (#xcannot be copied by any outside code).
Compared with structuredClone (built into Node 17+ and browsers):
deepClone above | structuredClone | |
|---|---|---|
Cycles, Date, RegExp, Map, Set | Yes | Yes |
| Class instances | Keeps the prototype | Drops it: you get a plain object |
| Functions | Copied by reference | Throws DataCloneError |
| Symbol keys, getters | Copies symbol keys; reads getters | Drops symbol keys; reads getters |
Typed arrays, Error, Blob | No | Yes |
In production code, prefer structuredClone for plain data. Write your own only when you need prototypes kept or functions passed through.
Test: const a = { d: new Date(0) }; a.self = a; const b = deepClone(a); b.self === b && b.d !== a.d
Deep equal
Spec. Two values are deeply equal if they are the same primitive, or objects with the same prototype and deeply equal own enumerable properties. Handle arrays, Date, RegExp, Map, Set and cycles.
function deepEqual(a, b, seen = new Map()) {
if (Object.is(a, b)) return true; // NaN equals NaN; +0 and -0 differ
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
if (seen.get(a) === b) return true; // already comparing this pair further up: a cycle
seen.set(a, b);
if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
if (a instanceof RegExp) return a.source === b.source && a.flags === b.flags;
if (a instanceof Map) {
if (a.size !== b.size) return false;
for (const [k, v] of a) if (!b.has(k) || !deepEqual(v, b.get(k), seen)) return false;
return true;
}
if (a instanceof Set) {
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false; // members compared by identity
return true;
}
if (Array.isArray(a) && a.length !== b.length) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const k of keysA) {
if (!Object.hasOwn(b, k) || !deepEqual(a[k], b[k], seen)) return false;
}
return true;
}
Edge cases they probe:
NaNvsNaN(equal byObject.is),0vs-0(not equal byObject.is; equal by===). State which rule you chose.{ a: undefined }vs{}: different key counts, so not equal.[1, , 3]vs[1, undefined, 3]: also not equal.[]vs{}: different prototypes.- Cycles: the
seenmap records pairs being compared; meeting the same pair again means "equal so far". Setmembers andMapkeys are compared by identity here. A deep comparison of object members is O(n²). Say so instead of writing it.- One deliberate difference from Node: two
Invalid Dates are equal here;util.isDeepStrictEqualsays they are not. Every other case in the test suite matches Node.
Test: deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }) && !deepEqual([1, 2], [2, 1])
Part 4: Function Wrappers
once
Spec. Return a function that runs fn the first time and returns that first result on every later call.
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true; // set first: a re-entrant call or a throw does not run fn again
result = fn.apply(this, args);
fn = undefined; // let the closure release fn
}
return result;
};
}
Edge cases they probe:
- Set the flag before calling
fn. Iffnre-enters the wrapper (a handler that triggers itself), it must not run twice. - If
fnthrows, does the next call retry? Here: no, it returnsundefined. Some teams want a retry. Ask which. - Forward
thisand arguments; releasefnso a large closure can be collected.
Test: let n = 0; const f = once(() => ++n); f(); f(); n === 1
memoize, and async memoize
Spec. Cache results by a key derived from the arguments. For async functions, cache the promise so concurrent callers share one call, and do not keep a failed promise.
function memoize(fn, keyOf = (...args) => JSON.stringify(args)) {
const cache = new Map();
return function (...args) {
const key = keyOf(...args);
if (cache.has(key)) return cache.get(key); // has(), not get(): undefined is a valid result
const value = fn.apply(this, args);
cache.set(key, value);
return value;
};
}
function memoizeAsync(fn, { keyOf = (...args) => JSON.stringify(args), ttlMs = Infinity } = {}) {
const cache = new Map(); // key -> { promise, expires }
return function (...args) {
const key = keyOf(...args);
const hit = cache.get(key);
if (hit && hit.expires > Date.now()) return hit.promise;
// Cache the promise, not the value: concurrent callers share one call.
// new Promise(...) turns a synchronous throw from fn into a rejection.
const promise = new Promise((resolve) => resolve(fn.apply(this, args)));
cache.set(key, { promise, expires: Date.now() + ttlMs });
promise.catch(() => {
// Do not cache failures. Check identity: a newer call may have replaced the entry.
if (cache.get(key)?.promise === promise) cache.delete(key);
});
return promise;
};
}
Edge cases they probe:
- Key strategy.
JSON.stringify(args)treats{a:1, b:2}and{b:2, a:1}as different keys, drops functions, turnsundefinedintonullinside arrays, throws on cycles andBigInt. For object arguments, pass akeyOf(such asuser => user.id) or key aWeakMapby identity. cache.has(key), notcache.get(key) !== undefined:undefinedcan be a valid result.- Caching the promise, not the value. If you
awaitfirst and then store, ten concurrent calls start ten requests (a stampede). Storing the promise makes them share one. - Rejected promises. Cache a rejection and every later call fails forever (the gym, exercise
06-cache-layer). The.catchremoves it. - The identity check. Without
cache.get(key)?.promise === promise, an old rejected call can delete a newer, healthy entry. The timeline below shows it. - Unbounded growth: a
Mapthat is only ever added to is a memory leak in a long-running server. Add a TTL, an LRU limit, or both.
Step through the case the identity check exists for:
Test: let calls = 0; const f = memoizeAsync(async (x) => (calls++, x)); await Promise.all([f(1), f(1)]); calls === 1
curry, variadic curry, compose / pipe
Spec. curry(f) returns a function that collects arguments across calls until it has f.length of them, then calls f. A variadic curry collects until it is called with no arguments. pipe(f, g, h)(x) is h(g(f(x))); compose runs right to left.
function curry(fn, arity = fn.length) {
return function curried(...args) {
if (args.length >= arity) return fn.apply(this, args);
return (...more) => curried.apply(this, [...args, ...more]);
};
}
function curryVariadic(fn) {
return function next(...args) {
return (...more) => (more.length === 0 ? fn(...args) : next(...args, ...more));
};
}
const pipe = (...fns) => (...args) =>
fns.length === 0 ? args[0] : fns.slice(1).reduce((acc, f) => f(acc), fns[0](...args));
const compose = (...fns) => pipe(...fns.slice().reverse());
Edge cases they probe:
fn.lengthdoes not count rest parameters or parameters after the first default.curry((a, b = 1) => …)has arity 1. Hence thearityparameter.- Partials must be reusable:
const add1 = add(1); add1(2)(3); add1(10)(10)both work, because each call builds a new argument array instead of mutating a shared one. A version that pushes into one closed-over array fails this. - The classic
sum(1)(2)(3)with no terminator needs either a final()(above) or avalueOf/toStringoverride, which only works when the result is coerced. Say which you are writing. pipe()with no functions is the identity. The first function in a pipe may take several arguments; the rest take one.- Async pipe:
fns.reduce((p, f) => p.then(f), Promise.resolve(x)).
Test: pipe(x => x + 1, x => x * 2)(3) === 8 && compose(x => x + 1, x => x * 2)(3) === 7
Part 5: Events and Callbacks
EventEmitter
Spec. on(name, fn) adds a listener, off(name, fn) removes one, once(name, fn) adds one that removes itself before its first call, emit(name, ...args) calls the listeners in order, synchronously, and returns whether there were any. emit('error') with no listener throws.
class EventEmitter {
#events = new Map(); // name -> array of listeners
on(name, listener) {
if (typeof listener !== 'function') throw new TypeError('listener must be a function');
if (!this.#events.has(name)) this.#events.set(name, []);
this.#events.get(name).push(listener);
return this;
}
off(name, listener) {
const list = this.#events.get(name);
if (!list) return this;
// Match a once() wrapper by its original listener. Remove the most recent match.
const i = list.findLastIndex((l) => l === listener || l.listener === listener);
if (i !== -1) list.splice(i, 1);
if (list.length === 0) this.#events.delete(name);
return this;
}
once(name, listener) {
const wrapper = (...args) => {
this.off(name, wrapper); // remove before calling, so a re-emit inside does not re-run it
listener.apply(this, args);
};
wrapper.listener = listener;
return this.on(name, wrapper);
}
emit(name, ...args) {
const list = this.#events.get(name);
if (!list || list.length === 0) {
if (name === 'error') {
const err = args[0];
throw err instanceof Error ? err : new Error(`Unhandled error. (${err})`);
}
return false;
}
// Iterate a copy: on/off during this emit affect the next emit, not this one.
for (const listener of [...list]) listener.apply(this, args);
return true;
}
listenerCount(name) {
return this.#events.get(name)?.length ?? 0;
}
}
Edge cases they probe:
- Removing a listener during
emit. Ifemitloops over the live array and listener A callsoff(B), thespliceshifts the array and the loop skips whichever listener came after A. Iterating a copy fixes it and matches Node: a listener removed during an emit still runs in that emit, and one added during an emit first runs on the next. The gym, exercise07-event-bus, has this bug. offfor aoncelistener. The array holds the wrapper, not your function, sooff(name, fn)must also matchwrapper.listener.onceremoves before calling. If the listener emits the same event again, it must not run a second time.- The
errorevent. No listener → throw. In Node, an unhandled'error'on a socket or stream crashes the process; that is by design. - A listener that throws stops the remaining listeners and the throw reaches the
emitcaller. Node behaves the same. If the interviewer wants isolation, wrap each call intry/catchand report errors after the loop. - Leaks. Every
onwithout anoffkeeps the listener and its closure alive. Node warns past 10 listeners per event (setMaxListeners). In React,oninuseEffectwithout returningoffadds one more on every mount. emitis synchronous. It is not a queue: there is no retry, no persistence, and a slow listener blocks the emitter.
Test: const log = []; const e = new EventEmitter(); const b = () => log.push('b'); e.on('x', () => e.off('x', b)).on('x', b); e.emit('x'); e.emit('x'); leaves log as ['b'].
promisify
Spec. Turn a Node-style function whose last argument is (err, value) => … into one that returns a promise.
function promisify(fn) {
return function (...args) {
return new Promise((resolve, reject) => {
// A sync throw inside the executor becomes a rejection.
// A callback called twice is harmless: a promise settles once.
fn.call(this, ...args, (err, value) => {
if (err) reject(err);
else resolve(value);
});
});
};
}
Edge cases they probe:
thisis forwarded:promisify(obj.method)called asobj.pm()still seesobj. A version using an arrow function for the returned wrapper loses it.- A callback called twice (a common bug in legacy modules) is harmless: the promise settles once and ignores the rest. A synchronous throw becomes a rejection because it happens inside the executor.
- Multiple success values (
cb(null, a, b)): Node'sutil.promisifykeeps only the first. Functions such asdns.lookupsetutil.promisify.customto return an object instead. Mention the symbol. if (err): a falsy error such as0is treated as success. That matches Node.- Do not promisify a function that takes its callback in the first position (
setTimeout(cb, ms)); Node'stimers/promisesexists for that.
Test: await promisify((a, b, cb) => cb(null, a + b))(1, 2) === 3
Part 6: Promise Combinators
These four are the most-asked polyfills after debounce. Chapter 03 builds a full MyPromise; here the combinators are written against the built-in Promise.
| Resolves when | Rejects when | Empty input | |
|---|---|---|---|
all | every input fulfils (values in input order) | the first input rejects | resolves [] |
allSettled | every input settles | never | resolves [] |
race | the first input settles, either way | (same) | pending forever |
any | the first input fulfils | every input rejects (AggregateError) | rejects AggregateError |
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const results = [];
let pending = 0;
let index = 0;
for (const item of iterable) {
const i = index++; // capture the position: results keep input order
pending++;
Promise.resolve(item).then((value) => {
results[i] = value;
if (--pending === 0) resolve(results);
}, reject); // first rejection wins; later ones are ignored
}
if (pending === 0) resolve(results); // empty input
});
}
function promiseAllSettled(iterable) {
return new Promise((resolve) => {
const results = [];
let pending = 0;
let index = 0;
const settle = (i, outcome) => {
results[i] = outcome;
if (--pending === 0) resolve(results);
};
for (const item of iterable) {
const i = index++;
pending++;
Promise.resolve(item).then(
(value) => settle(i, { status: 'fulfilled', value }),
(reason) => settle(i, { status: 'rejected', reason }),
);
}
if (pending === 0) resolve(results);
});
}
function promiseRace(iterable) {
return new Promise((resolve, reject) => {
for (const item of iterable) Promise.resolve(item).then(resolve, reject);
// Empty input: never settles. That is the spec.
});
}
function promiseAny(iterable) {
return new Promise((resolve, reject) => {
const errors = [];
let pending = 0;
let index = 0;
for (const item of iterable) {
const i = index++;
pending++;
Promise.resolve(item).then(resolve, (reason) => {
errors[i] = reason;
if (--pending === 0) reject(new AggregateError(errors, 'All promises were rejected'));
});
}
if (pending === 0) reject(new AggregateError([], 'All promises were rejected'));
});
}
Edge cases they probe:
- Order.
results.push(value)gives completion order. Captureiper item and writeresults[i]. - Counting. Do not check
results.length === n: assigningresults[4]first makes the length 5 while four slots are empty. Count down apendingcounter. - Empty input. Without the final
if (pending === 0),all([])never resolves. - Non-promise items.
Promise.resolve(item)wraps plain values and adopts thenables. Callingitem.thendirectly crashes on3. - Any iterable, not just arrays: a
Set, a generator. Usefor…of, not.lengthand an index. A non-iterable makesfor…ofthrow inside the executor, which becomes the rejection, as in native. - Fail-fast is not cancel. When
allrejects, the other inputs keep running and their results are dropped. To stop them, pass anAbortSignalinto the work (Chapter 06). anykeeps errors in input order onerr.errors.
Test: await promiseAll([sleep(20).then(() => 'a'), 'b']) deep-equals ['a', 'b']
Part 7: Timing
setInterval via setTimeout
Spec. Call fn every ms until stopped. Two properties native setInterval lacks: an async fn never overlaps with itself, and ticks stay on the start + n × ms grid instead of drifting.
The simple answer is recursive setTimeout:
function every(fn, ms) {
let timer;
const tick = async () => { await fn(); timer = setTimeout(tick, ms); }; // next one only after this one ends
timer = setTimeout(tick, ms);
return () => clearTimeout(timer);
}
It fixes overlap, but has three flaws. It drifts: each tick lands ms after the previous one finished. One rejection skips the reschedule, so the loop stops silently, and the unhandled rejection crashes a Node process by default. And stop() called while fn is running does nothing: the timer it clears has already fired, and the line after the await schedules a new one. The full version:
function setIntervalAsync(fn, ms, { onError = console.error } = {}) {
const start = performance.now();
let n = 0;
let timer;
let stopped = false;
function schedule() {
n++;
// Aim at start + n*ms, not "now + ms", so lateness does not add up (drift).
let target = start + n * ms;
const now = performance.now();
if (target < now) {
n = Math.ceil((now - start) / ms); // the callback overran: skip missed ticks, no burst
target = start + n * ms;
}
timer = setTimeout(run, target - now);
}
async function run() {
try {
await fn(); // the next tick is scheduled only after this one finishes: no overlap
} catch (err) {
onError(err);
} finally {
if (!stopped) schedule();
}
}
schedule();
return function stop() {
stopped = true;
clearTimeout(timer);
};
}
Edge cases they probe:
- Overlap. Native
setInterval(async () => …, 1000)starts a new run every second even if the last one is still awaiting a 5-second query. A cron-like sweeper then runs five copies at once. - Drift. Measure from the start time, not from "now". Timers fire late, never early, so "now + ms" accumulates the lateness.
- Overrun. If a run takes longer than
ms, skip the missed ticks instead of firing several back to back. - Stop while running. The
stoppedflag is checked afterfnsettles, sostop()called from insidefnor during theawaitworks. - Errors. Decide: keep going and report (here), or stop. Never leave it as an unhandled rejection.
performance.now()is monotonic;Date.now()can jump when the system clock is adjusted.
Test: an async fn that takes 25 ms on a 10 ms interval never has two runs active at once.
debounce and throttle
Spec. debounce(fn, wait) calls fn once, wait ms after the last call in a burst, with the latest arguments. throttle(fn, wait) calls fn at most once per wait ms: immediately on the first call (leading edge) and once more at the end of the window if calls arrived during it (trailing edge). Chapter 07 covers when to use which.
function debounce(fn, wait) {
let timer;
let lastArgs;
let lastThis;
function debounced(...args) {
lastArgs = args;
lastThis = this;
clearTimeout(timer); // every call restarts the wait
timer = setTimeout(() => {
timer = undefined;
fn.apply(lastThis, lastArgs); // trailing edge, latest arguments
}, wait);
}
debounced.cancel = () => {
clearTimeout(timer);
timer = undefined;
};
debounced.flush = () => {
if (timer === undefined) return;
clearTimeout(timer);
timer = undefined;
fn.apply(lastThis, lastArgs);
};
return debounced;
}
function throttle(fn, wait) {
let last = -Infinity; // time of the last real call
let timer;
let pendingArgs;
let pendingThis;
function invoke() {
last = Date.now();
timer = undefined;
const args = pendingArgs;
const ctx = pendingThis;
pendingArgs = pendingThis = undefined;
fn.apply(ctx, args);
}
function throttled(...args) {
pendingArgs = args; // the trailing call uses the latest arguments
pendingThis = this;
const remaining = wait - (Date.now() - last);
if (remaining <= 0) {
clearTimeout(timer);
invoke(); // leading edge
} else if (timer === undefined) {
timer = setTimeout(invoke, remaining); // one trailing call per window
}
}
throttled.cancel = () => {
clearTimeout(timer);
timer = undefined;
pendingArgs = pendingThis = undefined;
last = -Infinity;
};
return throttled;
}
Try both against a burst of keystrokes and scroll events. Drag the wait slider and watch which events actually call the handler:
Edge cases they probe:
thisand the latest arguments. Capture both on every call; an arrow function as the returned wrapper losesthis.cancel()(component unmount) andflush()(save the draft before navigating away). An interviewer often asks for them as the follow-up.- Leading vs trailing options. Leading-only debounce is a "first click wins" guard for a submit button.
- Throttle's trailing call must not fire immediately after a leading call. The
remainingcalculation schedules it at the end of the window. - A debounced async search does not cancel the request already in flight. Debounce cuts the number of calls; only an
AbortControlleror a "latest wins" check stops a stale response from landing (Chapter 05). - In React,
debounce(fn)inside the component body makes a new debounced function on every render, so nothing is ever debounced. Keep it in auseReforuseMemo.
Test: with fake timers, d(1); tick(50); d(2); tick(100) calls fn once, with 2.
asyncPool (concurrency limit)
Spec. Run fn(item) for every item with at most limit running at once. Resolve with the results in input order. Reject on the first failure.
async function asyncPool(limit, items, fn) {
if (!(limit >= 1)) throw new RangeError('limit must be at least 1');
const results = new Array(items.length);
let next = 0;
let failed = false;
async function worker() {
while (!failed && next < items.length) {
const i = next++; // claimed synchronously, so two workers never take the same item
try {
results[i] = await fn(items[i], i);
} catch (err) {
failed = true; // stop the other workers from starting new items
throw err;
}
}
}
const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
await Promise.all(workers);
return results;
}
Edge cases they probe:
- The claim must be synchronous.
const i = next++happens before anyawait, so two workers never take the same index. Put anawaitbetween readingnextand incrementing it and you have a check-then-act race inside a single thread. - Fail-fast does not stop the workers.
Promise.all(workers)rejects on the first error, but the other workers would keep pulling items. Thefailedflag stops them from starting new ones; the ones already running still finish. True cancellation needs a signal. limitlarger than the number of items: start only as many workers as items.limitof 0 would resolve immediately with an empty array of holes, so reject it.- An off-by-one (
<=instead of<) in the worker loop runsfn(undefined). That is the gym, exercise04-job-runner. - For a lazy source (a generator of 10 million IDs), share one iterator between the workers instead of indexing an array.
See the difference between serial, unbounded and pooled execution against a rate-limited API:
Test: five tasks with limit = 2 never have more than two active, and the results come back in input order.
sleep and retry
Spec. sleep(ms) resolves after ms, and rejects early if its AbortSignal fires. retry(fn) calls fn, and on failure waits and tries again, up to retries more times, with exponential backoff and jitter.
function sleep(ms, { signal } = {}) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason);
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
async function retry(fn, { retries = 3, baseMs = 100, maxMs = 5_000, shouldRetry = () => true, signal } = {}) {
for (let attempt = 0; ; attempt++) {
try {
return await fn(attempt); // `return await`: a rejection must land in this catch
} catch (err) {
if (attempt >= retries || !shouldRetry(err) || signal?.aborted) throw err;
const cap = Math.min(maxMs, baseMs * 2 ** attempt); // exponential backoff
await sleep(Math.random() * cap, { signal }); // full jitter
}
}
}
Edge cases they probe:
return await fn()insidetry. Withoutawait, the rejected promise is returned, not thrown, and thecatchnever sees it.retries: 3means up to 4 attempts. Say which you mean.- Do not retry everything. A
400or a validation error fails the same way every time.shouldRetryshould allow timeouts,429and5xx. HonourRetry-Afterwhen the server sends it. - Full jitter (
random() × cap) spreads clients out. A fixed delay makes every client that failed together retry together (a retry storm); theconcurrency-poolsim'sRetry stormtab shows it. - Retrying a non-idempotent call (charging a card, debiting credits, a paid LLM call) can do it twice. Send an idempotency key the server deduplicates on.
sleepremoves its abort listener when it resolves; otherwise a long-lived signal collects one listener per sleep.- Each attempt needs its own timeout.
retryaround a call that hangs forever waits forever.
Test: a function that fails twice then returns 'ok' resolves to 'ok' with retry(fn, { baseMs: 1 }) after 3 calls.
Part 8: Serialisation and the DOM
A JSON.stringify subset
Spec. Strings, numbers, booleans, null, arrays, plain objects, toJSON. No replacer or space.
const ESCAPES = { '"': '\\"', '\\': '\\\\', '\b': '\\b', '\f': '\\f', '\n': '\\n', '\r': '\\r', '\t': '\\t' };
const quote = (s) =>
'"' +
s.replace(/["\\\u0000-\u001f]/g, (c) => ESCAPES[c] ?? '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')) +
'"';
function stringify(value, key = '', stack = []) {
if (value !== null && typeof value === 'object' && typeof value.toJSON === 'function') {
value = value.toJSON(key); // Date has toJSON
}
if (value === null) return 'null';
switch (typeof value) {
case 'string': return quote(value);
case 'number': return Number.isFinite(value) ? String(value) : 'null'; // NaN, Infinity
case 'boolean': return String(value);
case 'bigint': throw new TypeError('Do not know how to serialize a BigInt');
case 'undefined':
case 'function':
case 'symbol': return undefined; // omitted in objects, null in arrays
}
if (stack.includes(value)) throw new TypeError('Converting circular structure to JSON');
stack.push(value);
let out;
if (Array.isArray(value)) {
// Array.from, not map: map skips holes and join would print them as empty.
out = '[' + Array.from(value, (v, i) => stringify(v, String(i), stack) ?? 'null').join(',') + ']';
} else {
const parts = [];
for (const k of Object.keys(value)) {
const s = stringify(value[k], k, stack);
if (s !== undefined) parts.push(quote(k) + ':' + s);
}
out = '{' + parts.join(',') + '}';
}
stack.pop(); // the same object twice in different branches is fine; only a cycle throws
return out;
}
Edge cases they probe:
undefined, functions and symbols are omitted as object values but becomenullin arrays;stringify(undefined)returnsundefined, not a string.NaNand±Infinitybecomenull;-0becomes"0";BigIntthrows.- Holes in arrays become
null.value.map(…)skips holes andjoinprints them as nothing, giving[1,,3], which is invalid JSON.Array.fromvisits every index. - Cycles throw. Use a stack of ancestors, not a set of everything seen: the same object appearing twice in different branches is legal.
- Not handled here: boxed primitives (
new Number(1)should print1), lone surrogates (escaped as\udXXXby the native),replacer,space.
Test: stringify({ a: [1, undefined, () => 1], b: undefined, d: new Date(0) }) === JSON.stringify(same)
getElementsByClassName-style traversal
Spec. Return every descendant element of root that has all the given classes, in document order.
function getByClassName(root, classNames) {
const wanted = classNames.split(/\s+/).filter(Boolean);
const out = [];
if (wanted.length === 0) return out;
const walk = (node) => {
for (const child of node.children) { // element children only, in document order
if (wanted.every((c) => child.classList.contains(c))) out.push(child);
walk(child); // the root itself is never included
}
};
walk(root);
return out;
}
Edge cases they probe:
node.children(elements only), notchildNodes(includes text and comment nodes).- The root itself is not included; that matches the native method.
- Several classes:
'a b'means both, in any order on the element. Split on whitespace and drop empty strings. - Native returns a live
HTMLCollectionthat updates as the DOM changes; this returns a static array. - Very deep trees: switch to an explicit stack. Push children in reverse so they pop in document order.
Test: getByClassName(document.body, 'card active').every(el => el.classList.contains('card'))
Spot the Bug
Drill 1 — The admin panel's bulk export
function promiseAll(promises) {
return new Promise((resolve, reject) => {
const results = [];
promises.forEach((p) => {
p.then((value) => {
results.push(value);
if (results.length === promises.length) resolve(results);
}).catch(reject);
});
});
}
const [user, invoices, usage] = await promiseAll([
fetchUser(id), fetchInvoices(id), fetchUsage(id),
]);
What's wrong?
Answer
Four bugs.
- Order.
pushstores values in completion order. IffetchUsagereturns first,userholds the usage data. The admin panel shows the wrong columns, only when the network is slow in a particular way. - Empty input never resolves:
forEachdoes nothing andresolveis never called. - Plain values fail:
p.thenon a non-promise throwsTypeError. The throw happens inside the executor, so one plain value rejects the whole call. - Only arrays.
promises.lengthandforEachfail for aSetor a generator.
for (const item of iterable) {
const i = index++;
pending++;
Promise.resolve(item).then((v) => { results[i] = v; if (--pending === 0) resolve(results); }, reject);
}
if (pending === 0) resolve(results);
Say it: "push records completion order, not input order; I capture the index per item, count down a pending counter instead of trusting length, and resolve immediately on empty input."
Drill 2 — The pricing lookup
const cache = new Map();
export async function getPlanPrice(planId) {
if (cache.has(planId)) return cache.get(planId);
const price = await fetch(`${BILLING_URL}/plans/${planId}`).then((r) => r.json());
cache.set(planId, price);
return price;
}
At 09:00 a marketing email goes out and the billing service's request rate jumps 400×. What's wrong?
Answer
No single-flight. The value is stored only after the await. Every request that arrives while the first fetch is in flight sees cache.has(planId) === false and starts its own fetch. It is check-then-act across an await, the same shape as SELECT then UPDATE without a lock. Also: no TTL, so a price change is never seen, and r.ok is never checked, so a 500 error body gets cached as the price.
export function getPlanPrice(planId) {
if (!cache.has(planId)) {
const p = fetch(`${BILLING_URL}/plans/${planId}`).then((r) => {
if (!r.ok) throw new Error(`billing ${r.status}`);
return r.json();
});
cache.set(planId, p); // store the promise synchronously, before any await
p.catch(() => { if (cache.get(planId) === p) cache.delete(planId); });
}
return cache.get(planId);
}
Add a TTL as in memoizeAsync above.
Say it: "The cache is filled after the await, so concurrent misses each go to the source. I cache the promise synchronously so they share one request, and I evict it if it rejects."
Drill 3 — The bound handler
Function.prototype.bind2 = function (ctx, ...preset) {
const fn = this;
return (...args) => fn.apply(ctx, [...preset, ...args]);
};
function Job(name) { this.name = name; this.createdAt = Date.now(); }
const BoundJob = Job.bind2({ tenant: 'acme' });
const job = new BoundJob('nightly-export');
What happens on the last line?
Answer
TypeError: BoundJob is not a constructor. The returned function is an arrow function, and arrow functions cannot be called with new. Even with a regular function, the body ignores new and runs Job with this = { tenant: 'acme' }, so name and createdAt are written onto the shared ctx object and job is an empty object. Every job overwrites the same ctx.
Fix: use a regular function, and check new.target:
function bound(...later) {
const args = [...preset, ...later];
if (new.target) return Reflect.construct(fn, args, new.target === bound ? fn : new.target);
return fn.apply(ctx, args);
}
if (fn.prototype) bound.prototype = fn.prototype;
return bound;
Say it: "A bound function called with new must ignore the bound this and construct the target. An arrow function can't be a constructor at all, so I return a regular function and branch on new.target."
The Causal Chains
- If
Promise.allstores results withpush - If a memoized async function stores the value after
await - If a rejected promise stays in the cache
- If
emititerates the live listener array and a listener removes itself - If
setIntervalruns an async job that can take longer than the interval - If
retryretries a non-idempotent call on timeout - If
bindreturns an arrow function - If
reducetreatsundefinedas "no initial value"
Review Checklist
When a codebase has its own versions of these helpers, check:
- Hand-rolled
Promise.allor pool: is input order kept? Is empty input handled? Does a failure stop new work? - Every memoize or cache helper: is the promise cached (single-flight)? Are rejections evicted? Is there a TTL or size bound? What is the key, and can two different inputs share it?
- Every
EventEmitteror pub/sub: is there anofffor eachon? Doesemititerate a copy? Is there an'error'listener on every stream and socket? setIntervalwith anasynccallback: can runs overlap? What happens on rejection?retryhelpers: backoff with jitter? A cap? Which errors are retried? Is the operation idempotent? Is there a per-attempt timeout?debounce/throttlein React: created once (useRef/useMemo), cancelled on unmount?deepCloneviaJSON.parse(JSON.stringify(x)): are thereDates,Maps orundefineds in that data?promisifywrappers: do they keepthis? Does the wrapped function ever call its callback twice?
If They Ask X, the Trap Is Y
| If they ask | The trap is |
|---|---|
call / apply | A string temp key that overwrites a real property; not deleting it when fn throws |
bind | new on the bound function; returning an arrow function; partial args order |
map / filter | Holes; reading length once; thisArg |
reduce | No initial value vs undefined initial value; holes before the seed; empty array |
flat | depth coercion (Infinity, 0, NaN); holes removed at every level |
Object.create | null prototype |
instanceof | Primitives on the left; Symbol.hasInstance; cross-realm arrays |
new | A constructor that returns an object; class targets |
| Deep clone | Cycles (register before recursing); Date, Map, Set; shared references |
| Deep equal | NaN, -0; { a: undefined } vs {}; cycles; arrays vs objects |
once | Re-entrant call before the flag is set; what happens after a throw |
memoize | Key collisions from JSON.stringify; undefined results; unbounded growth |
| Async memoize | Caching the value after await (stampede); caching rejections |
curry | fn.length with defaults and rest; partials sharing one mutable array |
EventEmitter | Removing during emit; off for a once listener; unhandled 'error' |
promisify | Losing this; multiple callback values |
Promise.all | push order; empty input; plain values; non-array iterables |
Promise.race | Empty input never settles; losers keep running |
Promise.any | AggregateError; empty input rejects |
setInterval via setTimeout | Overlap of async callbacks; drift; stopping mid-run |
debounce / throttle | this and latest args; cancel; trailing call timing; new instance per React render |
asyncPool | await between read and increment of the index; fail-fast not stopping workers |
retry | return without await in try; retrying 4xx; no jitter; non-idempotent calls |
JSON.stringify | undefined in objects vs arrays; holes; cycles vs repeated references |
| DOM traversal | childNodes vs children; including the root; multiple classes |
What's Next
Chapter 12 — The Gym has the practice repos. Start with exercise 10-polyfill-kata: stubs and tests for Promise.all / allSettled / any / race, debounce, throttle, bind, asyncPool, promisify and retry. Write each one cold, in under ten minutes, before checking it against this chapter.