Emmanuel Akinfulubi

JavaScript: Fundamental (ES6), part 2, 38–68

The second page of the same set, with heavier utilities.

Exercises 38 to 68 of 118

38 A Result type instead of exceptions

hard

Build Ok and Err with map, mapErr, andThen and unwrapOr, plus attempt(fn) that turns a throwing function into a value. Prove the mapper is never called on the failure path, and that a rejected promise becomes data rather than an exception.

Stuck? Three hints, smallest first
  1. 1. Two constructors, same method names, opposite behaviour. That is the entire pattern.
  2. 2. map on an Err returns the Err untouched and never calls your function.
  3. 3. andThen is for a step that can itself fail, so it returns a Result rather than a value.

The answer

const Ok = (value) => ({
  ok: true,
  value,
  map: (fn) => Ok(fn(value)),
  mapErr: () => Ok(value),
  andThen: (fn) => fn(value),
  unwrapOr: () => value,
});

const Err = (error) => ({
  ok: false,
  error,
  map: () => Err(error),                          // your function is never called
  mapErr: (fn) => Err(fn(error)),
  andThen: () => Err(error),
  unwrapOr: (fallback) => fallback,
});

const attempt = (fn) => { try { return Ok(fn()); } catch (err) { return Err(err.message); } };
const attemptAsync = (promise) => promise.then(Ok, (err) => Err(err.message));

// The methods make these objects awkward to compare, so project them first.
const shape = (result) => (result.ok ? ['ok', result.value] : ['err', result.error]);

let mapperCalls = 0;
const doubled = Ok(1500).map((amount) => { mapperCalls++; return amount * 2; });
const skipped = Err('till offline').map((amount) => { mapperCalls++; return amount * 2; });

const parsed = attempt(() => JSON.parse('{"till":"yaba"}'));
const broken = attempt(() => JSON.parse('{till:'));
const chained = Ok(3).andThen((n) => (n > 0 ? Ok(n * 100) : Err('not a positive count')));
const shortCircuit = Err('nothing to work with').andThen(() => Ok('never reached'));
const defaults = [Ok(10).unwrapOr(0), Err('boom').unwrapOr(0)];
const fromRejection = shape(await attemptAsync(Promise.reject(new Error('gateway down'))));
const tidied = shape(Err('ECONNRESET').mapErr((message) => 'network: ' + message));

console.log('success :', shape(doubled), '- mapper calls:', mapperCalls);
console.log('failure :', shape(skipped));
console.log('parse failure as data:', [broken.ok, broken.error.length > 0]);
console.log('from a rejection     :', fromRejection);

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('map transforms the success', shape(doubled), ['ok', 3000]);
test('and is simply not called on a failure', [shape(skipped), mapperCalls], [['err', 'till offline'], 1]);
test('attempt turns a throw into a value you can pass around', [parsed.ok, parsed.value.till], [true, 'yaba']);
test('so a parse failure stops being an exception', [broken.ok, typeof broken.error], [false, 'string']);
test('andThen chains a step that can itself fail', shape(chained), ['ok', 300]);
test('and short-circuits the moment something has failed', shape(shortCircuit), ['err', 'nothing to work with']);
test('unwrapOr is the one place a default appears', defaults, [10, 0]);
test('a rejected promise becomes an Err, so nothing throws', fromRejection, ['err', 'gateway down']);
test('mapErr rewrites the failure and leaves the success path alone', tidied, ['err', 'network: ECONNRESET']);

The point of a Result is that failure is in the type, so it cannot be forgotten. An exception is invisible in a function's signature: nothing tells a caller that JSON.parse throws, and the reminder arrives in production. A function returning Ok | Err forces the question at the call site, because there is no value to use until you have said what happens if there is none.

The second assertion is the whole mechanism in one line. Err.map ignores its argument, so a five-step pipeline over a failed Result runs none of the five and the original error falls out of the end unchanged. That is the same short-circuit a throw gives you, achieved without unwinding the stack — and unlike a throw, it does not skip past the caller who was meant to handle it.

Where this earns its keep: expected failures. A form field that will not parse, a lookup that may miss, a payment the bank declines — none of those are exceptional, and modelling them as exceptions means try/catch as ordinary control flow. Keep throw for the genuinely unexpected, where you want the stack unwound to a boundary that logs and gives up.

The cost is honest: two representations of failure in one codebase, and a conversion at every edge. attempt is that edge going in; unwrapOr, or a final if (!result.ok) throw, is the edge going out. Pick the boundary deliberately rather than letting both styles diffuse through every layer.

39 return risky() versus return await risky() inside try

hard

Return a promise from inside a try without awaiting it and watch the rejection walk straight past the catch. Add the await and watch it be caught. Then do the same with finally and find the cleanup that runs before the work has finished.

Stuck? Three hints, smallest first
  1. 1. return somePromise ends the try block immediately. The settlement happens afterwards, outside it.
  2. 2. The value the caller sees is identical either way, which is what makes this so easy to miss.
  3. 3. With finally the bug is louder: the release runs while the work is still going.

The answer

const log = [];

const chargeCard = async () => {
  await Promise.resolve();
  log.push('charge attempted');
  throw new Error('gateway declined');
};

const readReceipt = async () => {
  await Promise.resolve();
  await Promise.resolve();
  log.push('receipt read');
  return 'LAG-0039';
};

const noAwait = async () => {
  try { return chargeCard(); }                 // leaves the try before it settles
  catch { return 'handled here'; }
};

const withAwait = async () => {
  try { return await chargeCard(); }
  catch { return 'handled here'; }
};

const escaped = await noAwait().then((v) => 'resolved: ' + v, (err) => 'ESCAPED: ' + err.message);
const caught = await withAwait();

log.length = 0;
const releaseEarly = async () => {
  try { return readReceipt(); }
  finally { log.push('printer released'); }
};
const earlyValue = await releaseEarly();
const earlyOrder = log.slice();

log.length = 0;
const releaseAfter = async () => {
  try { return await readReceipt(); }
  finally { log.push('printer released'); }
};
const lateValue = await releaseAfter();
const properOrder = log.slice();

log.length = 0;
const finallyOnly = async () => {
  try { return chargeCard(); }
  finally { log.push('released too soon'); }
};
const stillRejects = await finallyOnly().catch((err) => err.message);
const tooSoon = log.slice();

console.log('no await   :', escaped);
console.log('with await :', caught);
console.log('finally    :', earlyOrder, 'vs', properOrder);
console.log('and still rejects:', stillRejects, tooSoon);

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('return without await escapes the catch that was meant to hold it', escaped, 'ESCAPED: gateway declined');
test('return await gives the catch something to catch', caught, 'handled here');
test('the resolved value is identical either way, which is why this hides', [earlyValue, lateValue], ['LAG-0039', 'LAG-0039']);
test('without the await, finally runs before the work has finished', earlyOrder, ['printer released', 'receipt read']);
test('with it, cleanup happens once the value is in hand', properOrder, ['receipt read', 'printer released']);
test('a try with only a finally still lets the rejection through', stillRejects, 'gateway declined');
test('and its cleanup had already run, before the failure even happened', tooSoon, ['released too soon', 'charge attempted']);

return somePromise inside a try completes the try block. The promise is then adopted by the async function's own promise, and it settles later — by which time the catch is long gone, so the rejection goes to the caller. The first two assertions are the same code differing by one keyword and producing opposite outcomes.

What makes this expensive is the third assertion: on the happy path the two versions are indistinguishable, so a missing await passes review, passes tests, and only shows up as an unhandled rejection when the dependency has a bad day. This is why return-await and no-return-await are both real lint rules, and why the modern advice is the boring one — inside a try or try/finally, always return await; outside one, it makes no difference worth arguing about.

With finally the failure is not even conditional. The last two assertions show the release running first, before the work it was protecting has attempted anything: the printer is freed, then the charge happens, then it fails. Connections closed mid-query, transactions committed before the write, spinners hidden while the request is still in flight — all the same missing keyword.

40 An async function never throws synchronously

fair

Write an async function that throws on a bad argument and call it inside a plain try/catch without awaiting. Show that nothing is caught, that a promise came back instead, and that the body still ran synchronously up to the throw. Then restore a synchronous throw with a plain wrapper.

Stuck? Three hints, smallest first
  1. 1. An async function converts every completion — value or throw — into a settled promise.
  2. 2. The body does run immediately, up to the first await. Only the resumption is deferred.
  3. 3. To fail fast on bad arguments, validate in a normal function that then calls the async one.

The answer

const order = [];

async function chargeTill(amount) {
  order.push('body starts, synchronously');
  if (amount <= 0) throw new RangeError('amount must be positive');
  await Promise.resolve();
  order.push('body resumes, a tick later');
  return `charged ₦${amount}`;
}

let syncCatch = 'nothing was thrown at the call site';
let returned;
try {
  returned = chargeTill(-1);
} catch {
  syncCatch = 'caught synchronously';
}
order.push('caller carries on');

const promiseShape = [typeof returned.then, returned instanceof Promise, returned.constructor.name];
const asRejection = await returned.then(() => 'resolved', (err) => err.name);
const orderAtThatPoint = order.slice();

const plainNumber = async () => 42;
const beforeAwait = plainNumber();
const wrapped = [typeof beforeAwait.then, await beforeAwait];

const receipt = await chargeTill(2500);

// Validate in a synchronous wrapper, and a bad call fails at the call site again.
const chargeGuarded = (amount) => {
  if (amount <= 0) throw new RangeError('amount must be positive');
  return chargeTill(amount);
};
let guarded = 'still nothing';
try { chargeGuarded(-1); } catch (err) { guarded = err.name; }

console.log('sync try/catch :', syncCatch);
console.log('what came back :', promiseShape, '-> rejects with', asRejection);
console.log('order          :', orderAtThatPoint);
console.log('guarded call   :', guarded);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('an async function that throws does not throw at the call site', syncCatch, 'nothing was thrown at the call site');
test('it hands back a promise instead', promiseShape, ['function', true, 'Promise']);
test('and the throw arrives later, as a rejection', asRejection, 'RangeError');
test('yet the body really did run up to the throw before the caller moved on', orderAtThatPoint, ['body starts, synchronously', 'caller carries on']);
test('returning a plain number still gives you a promise', wrapped, ['function', 42]);
test('which awaits to the value like anything else', receipt, 'charged ₦2500');
test('the resumed half of a body runs after the caller has carried on', order.at(-1), 'body resumes, a tick later');
test('validating in a plain wrapper is what restores a synchronous throw', guarded, 'RangeError');

async is a promise around a function body, and that includes its failures. Every way the body can end — a value, a throw, a rejected await — comes out as a settled promise, so a try/catch that does not await is watching the wrong thing entirely. It sees the function return successfully, because returning a rejected promise is returning successfully.

The fourth assertion is the half people get wrong in the other direction. Calling an async function is not lazy: the body runs immediately and synchronously up to the first await. So argument validation at the top does execute at once — it simply cannot throw at once, because the throw is captured and converted. Deferred delivery, not deferred execution.

Why the guarded version is worth the extra function: a bad argument is a programming error, and you want it at the call site, in the caller's stack, with no promise involved. Everything else — network, disk, the card network — is a runtime failure and belongs in the rejection. Splitting the two is also what makes a mistyped call fail in a unit test rather than in a log file.

One consequence worth remembering: fetch is not async-shaped in this respect. Some platform functions do throw synchronously on a malformed argument. Wrapping a call in try/catch and attaching a catch to the promise is not paranoia if you do not control the callee.

41 One request in flight, however many callers

hard

Write singleFlight(fn): while a call for a key is still running, every new caller for that key gets the same promise instead of a second request. Count the calls to prove it. Then show what happens after it settles, and what happens when it fails.

Stuck? Three hints, smallest first
  1. 1. A Map from key to the in-flight promise. Present means join, absent means start.
  2. 2. Delete the entry when it settles, or you have written a cache rather than a de-duplicator.
  3. 3. A failure should be shared by everyone currently waiting, and then forgotten so a retry can work.

The answer

function singleFlight(fn) {
  const inFlight = new Map();
  const wrapped = (key) => {
    if (inFlight.has(key)) return inFlight.get(key);          // join the one in progress
    const promise = (async () => fn(key))().finally(() => inFlight.delete(key));
    inFlight.set(key, promise);
    return promise;
  };
  wrapped.pending = () => inFlight.size;
  return wrapped;
}

let calls = 0;
const tillBalance = singleFlight(async (name) => {
  calls++;
  await Promise.resolve();
  await Promise.resolve();
  if (name === 'broken') throw new Error(name + ': 500 from the API');
  return name + ' balance ₦24,000';
});

const three = await Promise.all([tillBalance('yaba'), tillBalance('yaba'), tillBalance('yaba')]);
const callsAfterThree = calls;

const started = tillBalance('lekki');
const pendingDuring = tillBalance.pending();
await started;
const pendingAfter = tillBalance.pending();

await tillBalance('yaba');                                    // long settled, so a fresh call
const callsAfterLater = calls;

const bothFailed = await Promise.all([
  tillBalance('broken').catch((err) => err.message),
  tillBalance('broken').catch((err) => err.message),
]);
const callsAfterFailure = calls;
const retried = await tillBalance('broken').catch((err) => err.message);
const callsAfterRetry = calls;

const twoKeys = await Promise.all([tillBalance('ikeja'), tillBalance('ajah')]);
const callsAfterTwoKeys = calls;

console.log('three callers, calls made:', callsAfterThree);
console.log('pending during / after   :', pendingDuring, '/', pendingAfter);
console.log('shared failure           :', bothFailed[0] === bothFailed[1]);
console.log('calls, in order          :', [callsAfterThree, callsAfterLater, callsAfterFailure, callsAfterRetry, callsAfterTwoKeys]);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('three simultaneous callers cause one request', callsAfterThree, 1);
test('and all three get the same answer', three, ['yaba balance ₦24,000', 'yaba balance ₦24,000', 'yaba balance ₦24,000']);
test('exactly one entry is held while it runs, none afterwards', [pendingDuring, pendingAfter], [1, 0]);
test('a caller arriving after it settled starts a fresh request', callsAfterLater, 3);
test('everyone waiting on a failure gets the same failure, once', [bothFailed, callsAfterFailure], [['broken: 500 from the API', 'broken: 500 from the API'], 4]);
test('and the failure is not cached, so a later attempt really retries', [retried, callsAfterRetry], ['broken: 500 from the API', 5]);
test('different keys never share, so both run', [twoKeys.length, callsAfterTwoKeys], [2, 7]);
test('nothing is left in the map at the end', tillBalance.pending(), 0);

This is the fix for the stampede: a page where eight components each ask for the same balance on mount, and a server that receives eight identical requests. De-duplication happens on the client, in one Map, and the eight components are none the wiser because they all get a promise that resolves with the same value.

The line that decides what you have built is .finally(() => inFlight.delete(key)). With it, this is a de-duplicator: it collapses concurrent calls and nothing more, so data is never stale by more than one request. Take it out and you have an accidental permanent cache, where the first answer is the only answer your application will ever see again. Both are useful; confusing them is not.

Failure needs deciding too. Here the rejection is shared by everyone currently waiting — one request failed, so all eight callers hear about it — and then the entry is dropped, so the next attempt is a real attempt. Caching the rejection would mean a single blip poisoning the key for the lifetime of the page. Retrying per caller would mean the eighth caller starting request number eight, which is what you were trying to avoid.

The wrapper is written as (async () => fn(key))() rather than fn(key) on purpose. If fn is not async and throws synchronously, that would throw at the call site with no entry ever stored; wrapping it turns the throw into a rejection so both paths behave alike.

42 An Error does not survive JSON

fair

Stringify an error and see what arrives. Work out which of its fields are enumerable, then write the projection that actually carries a failure — name, message and cause chain — and confirm what comes back is no longer an Error.

Stuck? Three hints, smallest first
  1. 1. message and stack are not enumerable own properties, so JSON.stringify cannot see them.
  2. 2. Fields you assign yourself are enumerable, which is why some of the object survives and the important part does not.
  3. 3. Recurse down cause while it is still an Error, and build a plain object at each level.

The answer

const failure = new Error('yaba till timed out', { cause: new Error('ECONNRESET') });
failure.till = 'yaba';
failure.retryable = true;

const naive = JSON.stringify(failure);
const insideAReport = JSON.stringify({ status: 'failed', error: failure });
const enumerability = [failure.propertyIsEnumerable('message'), failure.propertyIsEnumerable('till')];
const spreadKeys = Object.keys({ ...failure });

function toPlain(err) {
  const out = { name: err.name, message: err.message };
  if (err.cause instanceof Error) out.cause = toPlain(err.cause);
  return out;
}

const serialised = JSON.stringify(toPlain(failure));
const revived = JSON.parse(serialised);
const afterTheRoundTrip = [revived instanceof Error, revived.cause.message];

console.log('naive       :', naive);
console.log('in a report :', insideAReport);
console.log('projected   :', serialised);
console.log('String()    :', String(failure));

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('an Error stringifies to nothing but the fields you added yourself', naive, '{"till":"yaba","retryable":true}');
test('so a report that embeds one loses the actual reason', insideAReport, '{"status":"failed","error":{"till":"yaba","retryable":true}}');
test('because message is not enumerable and your own fields are', enumerability, [false, true]);
test('spreading loses exactly the same things, just as quietly', spreadKeys, ['till', 'retryable']);
test('a deliberate projection is what crosses the wire', serialised, '{"name":"Error","message":"yaba till timed out","cause":{"name":"Error","message":"ECONNRESET"}}');
test('and what comes back is a plain object, not an Error', afterTheRoundTrip, [false, 'ECONNRESET']);
test('String reads the prototype instead, so it works', String(failure), 'Error: yaba till timed out');
test('which is why an error logged as an object alone tells you nothing', JSON.stringify({ oops: { ...new Error('lost entirely') } }), '{"oops":{}}');

{} in a log line is this, every time. message and stack live on the error as non-enumerable properties, name is inherited from the prototype, and cause is non-enumerable too — so JSON.stringify, object spread and Object.assign all walk away with the enumerable fields you happened to attach and nothing that identifies the failure.

Note the asymmetry in the first assertion: till and retryable made it through precisely because you assigned them. That is what makes the bug so confusing in practice — the log entry is not empty, it just contains everything except the part you needed.

So serialise deliberately. toPlain is eight lines and it is the whole fix: name, message, the cause chain, and stack too if the destination is private. Add errors to it when the input might be an AggregateError, as in exercise 36. Some codebases put this on Error.prototype.toJSON, which makes JSON.stringify work everywhere at the cost of patching a builtin — defensible in an application, rude in a library.

The last two assertions are the practical rule of thumb. String(err) and template interpolation read the prototype and work; handing the object to a JSON logger does not. And what survives a round trip is a plain object: instanceof Error is false on the other side, so anything downstream that branches on the class needs reviving explicitly.

43 Acquire and release, whatever happens

hard

Write withTill(name, use) that opens a resource, hands it to use, and closes it in a finally. Prove it closes on success, on a throw, and innermost-first when nested. Then drop the await and watch it close the till while the work is still running.

Stuck? Three hints, smallest first
  1. 1. Acquire outside the try, release in the finally. If the acquire throws there is nothing to release.
  2. 2. Nesting is a stack: the inner finally runs before the outer one.
  3. 3. The broken version differs from the correct one by one keyword — the same keyword as exercise 39.

The answer

const log = [];
let open = 0;

const openTill = (name) => {
  open++;
  log.push('open ' + name);
  return {
    name,
    print: (line) => 'receipt from ' + name + ': ' + line,
    close: () => { open--; log.push('close ' + name); },
  };
};

async function withTill(name, use) {
  const till = openTill(name);
  try { return await use(till); }
  finally { till.close(); }
}

async function withTillBroken(name, use) {
  const till = openTill(name);
  try { return use(till); }                  // no await: the finally runs at once
  finally { till.close(); }
}

const receipt = await withTill('yaba', async (till) => {
  await Promise.resolve();
  return till.print('₦2,500');
});
const afterSuccess = [open, log.slice()];

log.length = 0;
const failure = await withTill('lekki', async () => {
  throw new Error('printer jammed');
}).catch((err) => err.message);
const afterFailure = [open, log.slice()];

log.length = 0;
const nested = await withTill('outer', (a) => withTill('inner', async (b) => a.name + ' + ' + b.name));
const nestedOrder = log.slice();

log.length = 0;
const broken = await withTillBroken('ajah', (till) =>
  Promise.resolve().then(() => {
    log.push('used ' + till.name + ', open tills: ' + open);
    return till.name;
  })
);
const brokenOrder = log.slice();

console.log('receipt :', receipt);
console.log('failure :', failure, afterFailure);
console.log('nested  :', nestedOrder);
console.log('broken  :', brokenOrder);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the value comes back from use()', receipt, 'receipt from yaba: ₦2,500');
test('and the till was closed on the way out', afterSuccess, [0, ['open yaba', 'close yaba']]);
test('a throw inside use() closes it too', afterFailure, [0, ['open lekki', 'close lekki']]);
test('without swallowing the failure', failure, 'printer jammed');
test('nested resources close innermost first', nestedOrder, ['open outer', 'open inner', 'close inner', 'close outer']);
test('and nesting still produces the value', nested, 'outer + inner');
test('drop the await and the till is closed before the work touches it', brokenOrder, ['open ajah', 'close ajah', 'used ajah, open tills: 0']);
test('every till opened in this exercise ended up closed', open, 0);

This is the resource pattern in five lines, and the ordering of those five lines is the whole content. Acquire before the try, because a failed acquire has nothing to release and putting it inside gives you a finally that closes an undefined till. Release in the finally, because that is the only branch that runs on every exit: value, throw, and the return and continue from exercise 32.

Nesting works for free, and the fifth assertion is why: each finally belongs to its own call frame, so unwinding closes the inner till first. That is the same last-in-first-out discipline as a stack of locks, and taking them in a consistent order is what keeps two callers from deadlocking on each other.

The seventh assertion is exercise 39's missing keyword wearing different clothes, and it is worth seeing twice because the symptom here is so much worse. The till is closed, open is back to zero, and only then does the work run — against a closed resource. In real code that is a query on a released connection or a write to a closed file handle, and it fails intermittently, under load, in a way that looks like a driver bug.

The modern language has using and await using with Symbol.dispose, which is this pattern promoted into syntax. Until it is everywhere, a withX helper is how you make sure no caller has to remember the finally at all.

44 A deferred, and a promise for the next event

fair

Build a deferred() whose resolve and reject can be called from outside, then use it to write once(source): the next event from a small emitter, as a promise, with the listener removed again. Find the two ways a deferred goes wrong.

Stuck? Three hints, smallest first
  1. 1. The Promise executor runs synchronously, so assigning resolve out of it is safe to use immediately after.
  2. 2. once must unsubscribe inside its own handler, or every waiter leaves a listener behind.
  3. 3. A settled promise is final, and a promise nobody settles produces no error at all.

The answer

function deferred() {
  let resolve;
  let reject;
  const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
  return { promise, resolve, reject };
}

function emitter() {
  const handlers = [];
  return {
    on(fn) { handlers.push(fn); },
    off(fn) { const at = handlers.indexOf(fn); if (at !== -1) handlers.splice(at, 1); },
    emit(value) { for (const fn of handlers.slice()) fn(value); },
    get listeners() { return handlers.length; },
  };
}

// The next event, as a promise, cleaning up after itself.
function once(source) {
  const { promise, resolve } = deferred();
  const handler = (value) => { source.off(handler); resolve(value); };
  source.on(handler);
  return promise;
}

const gate = deferred();
let arrived = 'nothing yet';
gate.promise.then((value) => { arrived = value; });
for (let i = 0; i < 3; i++) await Promise.resolve();
const beforeResolve = arrived;
gate.resolve('opened');
const firstValue = await gate.promise;
gate.resolve('opened again');
const afterSecondResolve = await gate.promise;

const tills = emitter();
const next = once(tills);
const listenersWhileWaiting = tills.listeners;
tills.emit('till 3 opened');
const first = await next;
const listenersAfter = tills.listeners;

const failing = deferred();
failing.reject(new Error('till 4 never opened'));
const rejected = await failing.promise.catch((err) => err.message);

const forgotten = deferred();
let everSettled = false;
forgotten.promise.then(() => { everSettled = true; }, () => { everSettled = true; });
for (let i = 0; i < 8; i++) await Promise.resolve();

const late = emitter();
late.emit('fired before anyone subscribed');
const missed = await Promise.race([once(late), Promise.resolve('nothing arrived')]);

console.log('before resolve :', beforeResolve, '-> after:', firstValue);
console.log('listeners      :', listenersWhileWaiting, 'while waiting,', listenersAfter, 'after');
console.log('rejected       :', rejected);
console.log('never settled  :', everSettled, '- missed event:', missed);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('a deferred stays pending until someone outside settles it', beforeResolve, 'nothing yet');
test('and then carries the value they passed', firstValue, 'opened');
test('a second resolve changes nothing: settled is final', afterSecondResolve, 'opened');
test('once() hands you the next event as a promise', first, 'till 3 opened');
test('and unsubscribes itself, so waiting does not leak a listener', [listenersWhileWaiting, listenersAfter], [1, 0]);
test('reject works from outside in exactly the same way', rejected, 'till 4 never opened');
test('a deferred nobody settles stays pending for ever, with no warning', everSettled, false);
test('and an event fired before you subscribed is simply gone', missed, 'nothing arrived');

A deferred inverts the Promise constructor: instead of the work living inside the executor, the settling functions escape it and someone else decides. That is exactly what you need at the boundary between a callback API and a promise one — once is eleven lines and turns any emitter into something you can await. The platform now ships this as Promise.withResolvers(), which returns the same three fields; the hand-rolled version is worth writing once so the built-in holds no mystery.

The listener assertion is the part that separates a working once from a leaking one. Without source.off(handler), every caller who awaits an event leaves a closure attached to the emitter for the lifetime of the page — the classic slow leak in a component that subscribes on every render. Note also that emit iterates a copy of the handler list, because a handler that removes itself would otherwise shift the array mid-loop and skip the next one.

The last two assertions are the two failure modes, and neither one reports itself. A deferred nobody settles is not an error: it is a promise that stays pending, an await that never returns, and no warning anywhere. And an event that fires before you subscribe is missed entirely, which is why once must be called before whatever triggers the event, not after. Where you cannot guarantee that ordering, you want the buffered bridge from exercise 17 instead.

45 A circuit breaker on a fake clock

hard

Wrap a failing service in a breaker: after n failures it opens and refuses calls without making them; after a cooldown it allows one trial call; a failed trial reopens it and a successful one closes it. Drive the cooldown with an injected clock and count how many calls actually reached the service.

Stuck? Three hints, smallest first
  1. 1. Three states — closed, open, half-open — and the transitions are the whole exercise.
  2. 2. While open, throw before calling. The point of the breaker is the call that never happens.
  3. 3. In half-open exactly one call is allowed through. Its outcome decides the next state.

The answer

const fakeClock = () => {
  let now = 0;
  return { get time() { return now; }, advance(ms) { now += ms; } };
};

function breaker(call, { threshold = 3, cooldownMs = 1000, clock }) {
  let state = 'closed';
  let failures = 0;
  let openedAt = 0;

  const wrapped = async (...args) => {
    if (state === 'open') {
      if (clock.time - openedAt < cooldownMs) throw new Error('circuit open, not calling');
      state = 'half-open';                          // one trial call gets through
    }
    try {
      const value = await call(...args);
      state = 'closed';
      failures = 0;
      return value;
    } catch (err) {
      failures++;
      if (state === 'half-open' || failures >= threshold) {
        state = 'open';
        openedAt = clock.time;
        failures = 0;
      }
      throw err;
    }
  };
  wrapped.state = () => state;
  return wrapped;
}

const clock = fakeClock();
let calls = 0;
let healthy = false;
const gateway = async () => {
  calls++;
  if (!healthy) throw new Error('502 from the gateway');
  return 'receipt LAG-0045';
};
const guarded = breaker(gateway, { threshold: 3, cooldownMs: 1000, clock });

const messages = [];
for (let attempt = 0; attempt < 5; attempt++) {
  messages.push(await guarded().catch((err) => err.message));
}
const afterFive = [guarded.state(), calls];

clock.advance(999);
const stillOpen = await guarded().catch((err) => err.message);
clock.advance(1);
const trial = await guarded().catch((err) => err.message);
const afterTrial = [guarded.state(), calls];

clock.advance(1000);
healthy = true;
const recovered = await guarded();
const afterRecovery = [guarded.state(), calls];

healthy = false;
await guarded().catch(() => {});
await guarded().catch(() => {});
const afterTwoMore = [guarded.state(), calls];

console.log('first five  :', messages);
console.log('state, calls:', afterFive, '->', afterTrial, '->', afterRecovery);
console.log('recovered   :', recovered);
console.log('10 attempts made,', calls, 'calls actually reached the gateway');

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the first three failures go through to the service', messages.slice(0, 3), ['502 from the gateway', '502 from the gateway', '502 from the gateway']);
test('then it opens and refuses without calling anything', [messages.slice(3), afterFive], [['circuit open, not calling', 'circuit open, not calling'], ['open', 3]]);
test('one millisecond before the cooldown it is still refusing', stillOpen, 'circuit open, not calling');
test('at the cooldown exactly one trial call reaches the service', [trial, afterTrial[1]], ['502 from the gateway', 4]);
test('and a failed trial reopens it at once, with no second chance', afterTrial[0], 'open');
test('after the next cooldown a healthy trial closes it again', [recovered, afterRecovery], ['receipt LAG-0045', ['closed', 5]]);
test('closing resets the count, so two failures do not reopen it', afterTwoMore, ['closed', 7]);
test('so ten attempts cost the failing service seven calls, not ten', calls, 7);

A breaker is not about the caller, it is about the callee. A dependency that is down does not want your traffic — retries pile onto a service already struggling, and the queue of hanging requests takes your own process down with it. The last assertion is the entire justification: ten attempts, seven calls, and the three refusals were free and instant.

Half-open is the state people leave out, and without it the breaker is useless in both directions. Never probing means it stays open for ever; letting everything through after the cooldown means the full load returns to a service that may still be broken. One trial call, whose result decides, is the minimum that recovers safely — and the fifth assertion is the strictness that matters: a failed trial reopens immediately rather than allowing a second try.

The clock is injected for the same reason as in exercises 22 and 23. "One millisecond before the cooldown it still refuses, at the cooldown it allows a trial" is a statement about the comparison in the code; with a real clock it would be a statement about how busy the machine was, and the test would be flaky rather than wrong.

What this deliberately omits: a real breaker counts failures over a rolling window, not since the last success, because three failures spread over a day is not an outage. It also usually distinguishes failure types — a 404 is not a reason to open, as exercise 52 argues — and reports state changes, because a breaker that opens silently is an outage you find out about from customers.

46 A token bucket, and the burst it allows

hard

Write a token bucket: capacity tokens, one more every refillMs, never more than the capacity. Take tokens until it is empty, advance an injected clock and watch them come back. Then wrap a call so an empty bucket is a rejection rather than a wait.

Stuck? Three hints, smallest first
  1. 1. Do not run a timer. Work out how many tokens were earned from the elapsed time when someone asks.
  2. 2. Math.floor(elapsed / refillMs) tokens are due; move lastRefill forward by exactly that many intervals, not to now.
  3. 3. Cap at the capacity, or an idle bucket banks a burst it was never meant to allow.

The answer

const fakeClock = () => {
  let now = 0;
  return { get time() { return now; }, advance(ms) { now += ms; } };
};

function tokenBucket({ capacity, refillMs, clock }) {
  let tokens = capacity;
  let lastRefill = clock.time;

  const refill = () => {
    const earned = Math.floor((clock.time - lastRefill) / refillMs);
    if (earned > 0) {
      tokens = Math.min(capacity, tokens + earned);   // the cap is the whole point
      lastRefill += earned * refillMs;                // keep the remainder
    }
  };

  return {
    take() { refill(); if (tokens === 0) return false; tokens--; return true; },
    get available() { refill(); return tokens; },
  };
}

const limited = (fn, bucket) => async (...args) => {
  if (!bucket.take()) throw new Error('429: rate limit exceeded');
  return fn(...args);
};

const clock = fakeClock();
const bucket = tokenBucket({ capacity: 3, refillMs: 100, clock });

const burst = [bucket.take(), bucket.take(), bucket.take(), bucket.take()];
const emptied = bucket.available;

clock.advance(99);
const justBefore = [bucket.available, bucket.take()];
clock.advance(1);
const atTheRefill = [bucket.available, bucket.take(), bucket.available];

clock.advance(1000);                                  // ten intervals of idling
const banked = bucket.available;

let calls = 0;
const sendSms = limited(async (to) => { calls++; return 'sent to ' + to; }, bucket);
const sent = [
  await sendSms('0801').catch((err) => err.message),
  await sendSms('0802').catch((err) => err.message),
  await sendSms('0803').catch((err) => err.message),
  await sendSms('0804').catch((err) => err.message),
];

console.log('burst of four :', burst, '- left:', emptied);
console.log('at 99ms / 100ms:', justBefore, '/', atTheRefill);
console.log('after idling 1000ms:', banked, 'not 10');
console.log('sms            :', sent, '- calls made:', calls);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('a fresh bucket allows a burst up to its capacity, then refuses', burst, [true, true, true, false]);
test('and is empty afterwards', emptied, 0);
test('one millisecond before the interval nothing has come back', justBefore, [0, false]);
test('at the interval exactly one token is available, and one only', atTheRefill, [1, true, 0]);
test('idling for ten intervals still banks no more than the capacity', banked, 3);
test('the wrapper turns an empty bucket into a rejection, not a wait', sent, ['sent to 0801', 'sent to 0802', 'sent to 0803', '429: rate limit exceeded']);
test('so the refused call never reached the service', calls, 3);
test('and the bucket is empty again, having spent exactly three tokens', bucket.available, 0);

The trick is that there is no timer. Tokens are computed from elapsed time at the moment somebody asks, which means an idle bucket costs nothing at all — no interval, no callback, no wakeups on a page with fifty rate-limited widgets. A hundred buckets that nobody is using are a hundred integers.

lastRefill += earned * refillMs rather than lastRefill = clock.time is the detail worth stealing. Assigning now would throw away the remainder every time somebody checked, so a caller polling available every 99ms would earn tokens more slowly than one who never looked. Advancing by whole intervals makes the refill rate independent of who asks and when.

The cap is what makes it a token bucket rather than a counter. A burst of three is allowed on purpose: real traffic is bursty, and a limiter that permits exactly one call per 100ms with no slack rejects perfectly reasonable usage. Idling for a second and then getting ten calls, though, would defeat the limit entirely — which is the fifth assertion.

The wrapper here rejects, and that is a decision, not a default. Rejecting is right at an API boundary where the caller should back off and knows how. Queuing until a token frees up — the semaphore shape from exercise 29, with the clock instead of a permit count — is right inside a job runner where nobody is waiting on a response. Silently dropping is right for telemetry and almost nothing else.

47 Combinators over an object, keys kept

fair

Promise.all takes an array and gives you an array, so callers destructure by position. Write allObject and settledObject that take an object of promises and give back an object with the same keys. Check what happens to key order, to a rejection, and to a key that resolves to undefined.

Stuck? Three hints, smallest first
  1. 1. Take Object.keys once and reuse that array for both the values and the rebuild.
  2. 2. Object.fromEntries pairs the keys back up with the resolved values.
  3. 3. Key order comes from the object, not from which promise finished first.

The answer

const finishing = [];
const slow = (name, value, rounds) => (async () => {
  for (let i = 0; i < rounds; i++) await Promise.resolve();
  finishing.push(name);
  return value;
})();

async function allObject(tasks) {
  const keys = Object.keys(tasks);
  const values = await Promise.all(keys.map((key) => tasks[key]));
  return Object.fromEntries(keys.map((key, i) => [key, values[i]]));
}

async function settledObject(tasks) {
  const keys = Object.keys(tasks);
  const settled = await Promise.allSettled(keys.map((key) => tasks[key]));
  return Object.fromEntries(keys.map((key, i) => [
    key,
    settled[i].status === 'fulfilled'
      ? { ok: true, value: settled[i].value }
      : { ok: false, reason: settled[i].reason.message },
  ]));
}

const dashboard = await allObject({
  balance: slow('balance', 24000, 6),
  tills: slow('tills', 3, 2),
  area: 'lekki',                                  // a plain value passes through
});

const rejected = await allObject({
  balance: Promise.resolve(24000),
  tills: Promise.reject(new Error('till service offline')),
}).then(() => 'resolved', (err) => err.message);

const report = await settledObject({
  balance: Promise.resolve(24000),
  tills: Promise.reject(new Error('till service offline')),
});

const empty = await allObject({});
const missing = await allObject({ note: Promise.resolve(undefined) });
const withSymbol = await allObject({ [Symbol('hidden')]: Promise.resolve('dropped'), kept: Promise.resolve(1) });

console.log('dashboard :', dashboard);
console.log('finished in this order:', finishing);
console.log('rejected  :', rejected);
console.log('report    :', report);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('every key comes back with its resolved value', dashboard, { balance: 24000, tills: 3, area: 'lekki' });
test('the tills promise finished first, but the key order is the object\'s', [finishing, Object.keys(dashboard)], [['tills', 'balance'], ['balance', 'tills', 'area']]);
test('one rejection rejects the lot, exactly like Promise.all', rejected, 'till service offline');
test('while the settled version reports each key on its own', report, { balance: { ok: true, value: 24000 }, tills: { ok: false, reason: 'till service offline' } });
test('an empty object resolves to an empty object', [empty, Object.keys(empty)], [{}, []]);
test('a key that resolves to undefined is present but invisible to JSON', [Object.keys(missing), missing.note === undefined, JSON.stringify(missing)], [['note'], true, '{}']);
test('symbol keys are dropped, because Object.keys does not see them', Object.keys(withSymbol), ['kept']);
test('and the value under the surviving key is intact', withSymbol.kept, 1);

The array form makes callers count. const [balance, tills, area] = await Promise.all([...]) is fine with three entries and a liability with eight — insert a task in the middle and every name after it silently shifts by one. Naming the tasks removes the positional coupling entirely, and it reads better at the call site too.

Key order is worth being precise about. It is the object's own insertion order, fixed when Object.keys runs, and completely unrelated to which promise settled first — the second assertion shows tills finishing before balance and the result still reading balance, tills, area. That determinism is the reason this shape is safe to snapshot in a test.

The undefined assertion is a JSON trap rather than a promise one, and it catches people out precisely here, in code that builds a response object. The key exists, Object.keys reports it, and JSON.stringify drops it, so a client sees no field at all rather than a null. If the distinction between "missing" and "empty" matters to the consumer, map undefined to null before serialising.

Rejection behaviour is inherited, not designed: allObject rejects on the first failure and loses the successes, because Promise.all does. settledObject is the version to reach for when the keys are independent — a dashboard where a broken till service should still let you see the balance.

48 A pipeline, and the reduce that looks like one

fair

Thread an order through three async steps, each one using the last one's output. Write it with reduce over then and again with a plain loop. Then show two things that are not pipelines: mapping the steps in parallel, and an async reducer whose accumulator is never awaited.

Stuck? Three hints, smallest first
  1. 1. fns.reduce((acc, fn) => acc.then(fn), Promise.resolve(input)) builds the chain without running anything twice.
  2. 2. In parallel every step sees the original input, so their outputs cannot see each other.
  3. 3. Inside an async reducer the accumulator is a promise. Adding to it without awaiting stringifies it.

The answer

const order = { ref: 'LAG-0048', subtotal: 24000, area: 'lekki' };
const trace = [];

const steps = [
  async (o) => { trace.push('vat'); return { ...o, vat: Math.round(o.subtotal * 0.075) }; },
  async (o) => { trace.push('delivery'); return { ...o, delivery: o.area === 'lekki' ? 1500 : 800 }; },
  async (o) => { trace.push('total'); return { ...o, total: o.subtotal + o.vat + o.delivery }; },
];

const pipe = (fns) => (input) => fns.reduce((acc, fn) => acc.then(fn), Promise.resolve(input));

const runInOrder = async (fns, input) => {
  let acc = input;
  for (const fn of fns) acc = await fn(acc);
  return acc;
};

const piped = await pipe(steps)(order);
const traceFromPipe = trace.slice();
const looped = await runInOrder(steps, order);

// Not a pipeline: every step gets the original order, so nothing composes.
const inParallel = await Promise.all(steps.map((fn) => fn(order)));
const parallelKeys = inParallel.map((o) => Object.keys(o).slice(3));

const awaited = await [1, 2, 3].reduce(async (sum, n) => (await sum) + n, Promise.resolve(0));
const forgotten = await [1, 2, 3].reduce(async (sum, n) => sum + n, Promise.resolve(0));

let stepsRun = 0;
const failing = [
  async (n) => { stepsRun++; return n + 1; },
  async () => { stepsRun++; throw new Error('rate lookup failed'); },
  async (n) => { stepsRun++; return n * 100; },
];
const stopped = await pipe(failing)(1).then(() => 'finished', (err) => err.message);

const unchanged = await pipe([])(order);

console.log('piped   :', piped.total, 'from', traceFromPipe);
console.log('parallel:', parallelKeys);
console.log('reduce  :', awaited, 'vs', forgotten);
console.log('stopped :', stopped, 'after', stepsRun, 'steps');

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the pipeline threads each output into the next step', piped, { ref: 'LAG-0048', subtotal: 24000, area: 'lekki', vat: 1800, delivery: 1500, total: 27300 });
test('and runs them in array order, one at a time', traceFromPipe, ['vat', 'delivery', 'total']);
test('the plain loop produces exactly the same thing', looped, piped);
test('mapping in parallel gives three partial orders instead of one', parallelKeys, [['vat'], ['delivery'], ['total']]);
test('an async reducer works when the accumulator is awaited', awaited, 6);
test('and stringifies the promise when it is not, losing every earlier step', forgotten, '[object Promise]3');
test('a failing step stops the pipeline, so the third never runs', [stopped, stepsRun], ['rate lookup failed', 2]);
test('an empty pipeline hands the input straight back', unchanged, order);

Sequential and parallel are not styles, they are a question about the data. Each step here needs the field the previous one added, so they must run in order — the fourth assertion is what parallelism does to a pipeline: three objects, each holding one of the three fields, none of them a complete order. Independent work is the opposite case, and Promise.all is right there instead, as exercise 26 argues at length.

reduce over then is worth recognising because you will read it. It builds the whole chain in one synchronous pass and starts nothing extra: each acc.then(fn) is the promise the next step is attached to. The loop version is easier to read and does the same thing, so prefer it unless you actually want the composed function pipe gives you.

The sixth assertion is the trap. Inside an async reducer the accumulator is always a promise, so sum + n adds a number to a promise, JavaScript coerces the promise to ‘[object Promise]’, and nothing throws. Look closely at what survives: ‘[object Promise]3’, the last step only, because each round concatenated onto a freshly stringified promise and discarded what came before. A total that is silently wrong beats an exception for time spent debugging. (await sum) + n is the fix; a plain loop is the better fix, because there is nothing to forget.

The last two are the ordinary contract of a pipeline: a failure stops it where it happened, with the steps after it unrun, and an empty list of steps is the identity function rather than an error.

49 An async generator does nothing until you pull it

hard

Compare two calls that look identical. Calling an async function runs its body immediately, up to the first await. Calling an async generator runs none of it. Prove both, then show that take(2) of five values produces exactly two, and that breaking out leaves the body unfinished.

Stuck? Three hints, smallest first
  1. 1. Track three things: whether the body started, how many values it produced, and whether it ran to the end.
  2. 2. Each next() resumes the body until the next yield and no further.
  3. 3. break in a for await closes the iterator, so the body stops where it was suspended.

The answer

function readingsFrom(values) {
  const state = { started: false, produced: 0, finished: false };
  async function* generate() {
    state.started = true;
    for (const value of values) {
      state.produced++;
      await Promise.resolve();                    // pretend the meter is slow
      yield value;
    }
    state.finished = true;
  }
  return { readings: generate(), state };
}

async function take(n, iterable) {
  const out = [];
  for await (const value of iterable) {
    out.push(value);
    if (out.length === n) break;
  }
  return out;
}

const lazy = readingsFrom([24, 25, 26, 27, 28]);
const beforeAnyPull = [lazy.state.started, lazy.state.produced];

const eagerLog = [];
async function eager() {
  eagerLog.push('async function body started');
  await Promise.resolve();
  eagerLog.push('and resumed later');
}
eager();
const eagerRanAtOnce = eagerLog.slice();

const first = await lazy.readings.next();
const afterOnePull = [lazy.state.started, lazy.state.produced, lazy.state.finished];

const partial = readingsFrom([24, 25, 26, 27, 28]);
const twoOfFive = await take(2, partial.readings);
const afterBreak = [partial.state.produced, partial.state.finished];

const whole = readingsFrom([24, 25, 26]);
const everything = [];
for await (const value of whole.readings) everything.push(value);
const afterDraining = [whole.state.produced, whole.state.finished];
const pastTheEnd = await whole.readings.next();

let endless = 0;
async function* forEver() {
  while (true) {
    endless++;
    await Promise.resolve();
    yield endless;
  }
}
const firstThree = await take(3, forEver());

console.log('generator, before any pull:', beforeAnyPull);
console.log('async function, no pull   :', eagerRanAtOnce);
console.log('after one next()          :', afterOnePull, first.value);
console.log('take(2) of five           :', twoOfFive, afterBreak);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('creating an async generator runs none of its body', beforeAnyPull, [false, 0]);
test('whereas calling an async function runs it at once, up to the first await', eagerRanAtOnce, ['async function body started']);
test('one next() starts the body and produces exactly one value', [afterOnePull, first.value], [[true, 1, false], 24]);
test('take(2) of five values produces two, not five', [twoOfFive, afterBreak[0]], [[24, 25], 2]);
test('and breaking out leaves the body unfinished, its last line unrun', afterBreak[1], false);
test('draining it fully does reach the end', afterDraining, [3, true]);
test('once done it stays done, and produces nothing more', [pastTheEnd.done, pastTheEnd.value === undefined, afterDraining[0]], [true, true, 3]);
test('so an endless generator is safe to take three values from', [firstThree, endless], [[1, 2, 3], 3]);

The first two assertions are the same syntax with opposite behaviour, and it is worth knowing which is which. async function starts working the moment you call it — exercise 40 makes that point from the error-handling side. async function* starts nothing: you get an object with a next() method and the body waits, having executed not one line. That is why an async generator can validate nothing at call time and why a bad argument surfaces on the first pull.

Laziness is per value, not per stream, which is what makes the fourth assertion possible: five values were available, two were asked for, and the meter was read exactly twice. Over a paged API that is two requests instead of ten; over an infinite sequence it is the difference between working and hanging.

The fifth is the cost of that design, and exercise 15 chases it further. A body suspended at a yield and then closed never runs its remaining lines — no completion log, no final flush, no finished = true — unless the cleanup sits in a finally, which break does honour. Anything a producer must do on the way out belongs there and nowhere else.

50 Results as they complete, not in order

hard

Write asCompleted(tasks), an async generator that yields each result the moment it settles, tagged with its index. Drive three tasks of different lengths from an injected clock and show the fastest arriving first, while Promise.all over the same promises still reports input order.

Stuck? Three hints, smallest first
  1. 1. Keep a Map from index to a promise that resolves to { index, value } or { index, reason } — never rejects.
  2. 2. Promise.race over the remaining entries gives you the next one to settle; delete it and race again.
  3. 3. Wrapping each task so it cannot reject is what stops one failure from ending the stream.

The answer

function manualClock() {
  let now = 0;
  const timers = [];
  return {
    get time() { return now; },
    get pending() { return timers.length; },
    wait(ms) { return new Promise((resolve) => timers.push({ at: now + ms, resolve })); },
    async advance(ms) {
      now += ms;
      for (const timer of timers.filter((t) => t.at <= now)) {
        timers.splice(timers.indexOf(timer), 1);
        timer.resolve();
      }
      for (let i = 0; i < 20; i++) await Promise.resolve();   // let the waiters run
    },
  };
}

async function* asCompleted(tasks) {
  const pending = new Map(
    tasks.map((task, index) => [
      index,
      Promise.resolve(task).then(
        (value) => ({ index, value }),
        (reason) => ({ index, reason })       // a failure is a result, not an exit
      ),
    ])
  );
  while (pending.size) {
    const settled = await Promise.race(pending.values());
    pending.delete(settled.index);
    yield settled;
  }
}

const clock = manualClock();
const after = (ms, name) => clock.wait(ms).then(() => name);
const tasks = [after(300, 'ikeja'), after(100, 'yaba'), after(200, 'lekki')];

const arrivals = [];
const draining = (async () => {
  for await (const settled of asCompleted(tasks)) arrivals.push([settled.index, settled.value]);
})();

await clock.advance(100);
const firstOut = arrivals.slice();
await clock.advance(100);
await clock.advance(100);
await draining;
const inInputOrder = await Promise.all(tasks);

const second = manualClock();
const later = (ms, name) => second.wait(ms).then(() => name);
const breaks = (ms, message) => second.wait(ms).then(() => { throw new Error(message); });
const mixed = [];
const withFailure = (async () => {
  for await (const settled of asCompleted([later(300, 'ikeja'), breaks(100, 'yaba offline'), later(200, 'lekki')])) {
    mixed.push(settled.reason ? 'ERR ' + settled.reason.message : settled.value);
  }
})();
await second.advance(100);
await second.advance(100);
await second.advance(100);
await withFailure;

const nothing = [];
for await (const settled of asCompleted([])) nothing.push(settled);

console.log('first out    :', firstOut);
console.log('all arrivals :', arrivals);
console.log('Promise.all  :', inInputOrder);
console.log('with a failure:', mixed);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the first result out is the fastest, not the first in the list', firstOut, [[1, 'yaba']]);
test('everything arrives, in the order it finished', arrivals.map((a) => a[1]), ['yaba', 'lekki', 'ikeja']);
test('each result knows which input it came from', arrivals.map((a) => a[0]), [1, 2, 0]);
test('while Promise.all over the very same promises reports input order', inInputOrder, ['ikeja', 'yaba', 'lekki']);
test('a rejection is yielded as data and the stream carries on', mixed, ['ERR yaba offline', 'lekki', 'ikeja']);
test('so every input is accounted for, failures included', mixed.length, 3);
test('an empty list ends the loop immediately', nothing, []);
test('and no timer is left behind on either clock', [clock.pending, second.pending], [0, 0]);

Promise.all makes you wait for the slowest before you can use the fastest. That is right when the results are one thing — a page that needs all three lookups — and wrong when they are independent: three thumbnails, three receipts, three rows to stream into a table. This generator gives you each one at the moment it is ready, and the second assertion is the difference in one line: yaba at 100ms rather than everything at 300ms.

The design decision is the wrapping. Each task is turned into a promise that cannot reject, carrying either a value or a reason, which is what lets one failure be yielded and stepped over instead of ending the loop the way a rejection inside for await does in exercise 16. The index travels with it because completion order destroys the correlation you get free from an array — without it, you know a result arrived but not what it was for.

Two details in the loop. Promise.race is called afresh each round over only the remaining entries, so a settled promise is never raced again — leave it in the map and the race returns the same result for ever. And the Map is keyed by index rather than by promise, so delete is exact even if two tasks are the same promise object.

The clock is stepped 100ms at a time on purpose. Advancing 300ms in one jump resolves all three timers in the same instant, and the completion order would then be the order they were created — which would make the test pass for the wrong reason.

51 Anything can be thrown, so normalise it

fair

throw accepts any value. Catch a string, a number, undefined and an object that merely looks like an error, and show what your handler gets. Then write normalise(value) that turns each of them into a real Error without losing the original.

Stuck? Three hints, smallest first
  1. 1. The catch block runs for every one of them, including throw undefined.
  2. 2. Reading .message off a non-object is undefined; reading it off undefined throws inside your handler.
  3. 3. An object with a message property is not an Error. Keep it as evidence rather than trusting it.

The answer

const caught = (fn) => { try { fn(); return 'nothing was thrown'; } catch (err) { return err; } };

const aString = caught(() => { throw 'till offline'; });
const aNumber = caught(() => { throw 404; });
const nothing = caught(() => { throw undefined; });
const lookalike = caught(() => { throw { code: 'E_TILL', message: 'looks like an error' }; });

const handlerThrew = (() => {
  try {
    try { throw undefined; }
    catch (err) { return 'message was ' + err.message; }     // the handler is now the bug
  } catch (err) { return err.name; }
})();

function normalise(value) {
  if (value instanceof Error) return value;
  if (typeof value === 'string') return new Error(value);
  if (value !== null && typeof value === 'object' && typeof value.message === 'string') {
    return Object.assign(new Error(value.message), { thrown: value });
  }
  return new Error('non-error thrown: ' + String(value));
}

const real = new RangeError('a genuine error');
const normalised = [aString, aNumber, nothing, lookalike, real].map(normalise);
const messages = normalised.map((err) => err.message);
const allErrors = normalised.every((err) => err instanceof Error);
const untouched = normalise(real) === real;
const evidenceKept = normalised[3].thrown.code;
const fromRejection = await Promise.reject('a rejected string').catch((value) => [typeof value, normalise(value).message]);

console.log('a thrown string  :', typeof aString, JSON.stringify(aString), '- message:', aString.message);
console.log('throw undefined  :', nothing === undefined, '- and the handler:', handlerThrew);
console.log('normalised       :', messages);
console.log('evidence kept    :', normalised[3].thrown);

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('a thrown string arrives as a string, with no message of its own', [typeof aString, aString.message === undefined], ['string', true]);
test('a number too, and neither has a stack', [aNumber, normalised[1] instanceof Error], [404, true]);
test('catch runs even for throw undefined', nothing === undefined, true);
test('and reading .message off it makes the handler itself throw', handlerThrew, 'TypeError');
test('an object with a message property is still not an Error', [lookalike instanceof Error, lookalike.message], [false, 'looks like an error']);
test('normalise gives every one of them a real Error with a usable message', [allErrors, messages], [true, ['till offline', 'non-error thrown: 404', 'non-error thrown: undefined', 'looks like an error', 'a genuine error']]);
test('a real error passes through as the same object, keeping its stack', untouched, true);
test('and a lookalike keeps the original as evidence', evidenceKept, 'E_TILL');
test('a rejection with a non-Error needs exactly the same treatment', fromRejection, ['string', 'a rejected string']);

throw is untyped, so catch is untyped. Every handler you write is handed a value of unknown shape, and the habit of reaching straight for err.message is what turns somebody else's careless throw ‘failed’ into a TypeError inside your error handling — the fourth assertion, and the reason a crash report sometimes points at the catch block rather than the failure.

The lookalike is the interesting case. Objects with a message field come out of older libraries, out of JSON error responses, and out of anything that crossed a serialisation boundary as in exercise 42. Treating it as an Error because it quacks like one loses the class and the stack; wrapping it and keeping the original under thrown gives you both a real error to propagate and the raw payload to log.

Two rules follow. In your own code, always throw new Error(...) — a subclass from exercise 34 where it helps dispatch — because a string reason has no stack, no name and nowhere to hang a cause. And at every boundary you do not control, normalise on the way in. TypeScript makes this unavoidable rather than optional: catch (err) is typed unknown under modern settings, precisely because the language cannot promise you anything better.

52 Retry only what is worth retrying

hard

Retrying a declined card is pointless and retrying a timeout is essential, so make the retry take a predicate. Give it two error classes and an HTTP status, and prove that a permanent failure costs zero waits and one attempt while a transient one is retried with a growing gap. Exercise 22 is the backoff schedule; this is about which failures reach it at all.

Stuck? Three hints, smallest first
  1. 1. Ask the predicate before the wait. A failure you will not retry should not cost even one delay.
  2. 2. Class for your own failures, status code for a failure that arrived over the wire: 4xx is your fault, 5xx is theirs.
  3. 3. When you refuse to retry, wrap the failure rather than replacing it, so the caller still has the original.

The answer

class TransientError extends Error {
  constructor(message, options) { super(message, options); this.name = 'TransientError'; }
}
class PermanentError extends Error {
  constructor(message, options) { super(message, options); this.name = 'PermanentError'; }
}

const fakeClock = () => {
  let now = 0;
  const waits = [];
  return {
    get time() { return now; },
    get waits() { return waits.slice(); },
    wait(ms) { waits.push(ms); now += ms; return Promise.resolve(); },
  };
};

const worthRetrying = (err) =>
  err instanceof TransientError || (typeof err.status === 'number' && err.status >= 500);

async function retryIf(task, { attempts = 4, baseMs = 100, retryable, clock }) {
  let last;
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try { return { value: await task(attempt), attempts: attempt }; }
    catch (err) {
      last = err;
      if (!retryable(err)) throw new Error('not retryable: ' + err.name, { cause: err });
      if (attempt === attempts) break;
      await clock.wait(baseMs * attempt);
    }
  }
  throw new Error('gave up after ' + attempts + ' attempts', { cause: last });
}

const withStatus = (status, message) => Object.assign(new Error(message), { status });

const flaky = fakeClock();
let tries = 0;
const recovered = await retryIf(async () => {
  tries++;
  if (tries < 3) throw new TransientError('socket hang up');
  return 'receipt LAG-0052';
}, { baseMs: 100, retryable: worthRetrying, clock: flaky });

const declined = fakeClock();
const refused = await retryIf(async () => { throw new PermanentError('card declined'); },
  { retryable: worthRetrying, clock: declined }).catch((err) => err);

const serverError = fakeClock();
let fiveHundreds = 0;
const gaveUpOn500 = await retryIf(async () => { fiveHundreds++; throw withStatus(503, 'service unavailable'); },
  { attempts: 3, baseMs: 100, retryable: worthRetrying, clock: serverError }).catch((err) => err);

const notFound = fakeClock();
let lookups = 0;
const gaveUpOn404 = await retryIf(async () => { lookups++; throw withStatus(404, 'no such till'); },
  { attempts: 3, retryable: worthRetrying, clock: notFound }).catch((err) => err.message);

const clean = fakeClock();
const firstTime = await retryIf(async () => 'nothing to retry', { retryable: worthRetrying, clock: clean });

console.log('recovered  :', recovered, 'after waits', flaky.waits);
console.log('declined   :', refused.message, '/ cause:', refused.cause.name, '- waits:', declined.waits);
console.log('503        :', gaveUpOn500.message, 'after', fiveHundreds, 'attempts and waits', serverError.waits);
console.log('404        :', gaveUpOn404, 'after', lookups, 'attempt');

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('a transient failure is retried until it works', recovered, { value: 'receipt LAG-0052', attempts: 3 });
test('with a growing gap, and no real time spent', [flaky.waits, flaky.time], [[100, 200], 300]);
test('a permanent failure is not retried even once', [declined.waits, refused.message], [[], 'not retryable: PermanentError']);
test('and the original comes along as the cause, still typed', [refused.cause instanceof PermanentError, refused.cause.message], [true, 'card declined']);
test('a 503 is somebody else\'s problem, so it is retried to exhaustion', [fiveHundreds, serverError.waits], [3, [100, 200]]);
test('and the last failure is kept as the cause of giving up', gaveUpOn500.cause.status, 503);
test('a 404 is your problem, so it is attempted once and no more', [lookups, notFound.waits, gaveUpOn404], [1, [], 'not retryable: Error']);
test('a task that works first time waits for nothing at all', [firstTime.attempts, clean.waits], [1, []]);

A retry loop without a predicate is a way of turning one wrong answer into four. A declined card, a validation failure, a missing record and a malformed request are all settled — the answer will be identical in 200ms, so the only things retrying buys are latency for the customer and load for the service. The third and seventh assertions are the same rule from two angles: no waits, one attempt, and the failure handed straight back.

Where the predicate looks matters as much as what it says. Asking before the wait is what makes "not retryable" cost nothing; asking after would still delay the caller for a failure you had already decided not to retry. And wrapping with cause rather than rethrowing plainly lets the caller tell "this failed and we did not try again" from "this failed four times", which are different operational events.

The rule of thumb encoded in worthRetrying: your own classes for failures you raise, status codes for failures that arrive over the wire, 5xx and timeouts retryable, 4xx not. Two exceptions worth remembering — 429 is retryable but only after the interval the server names in Retry-After, and 408 and 409 often are too. Also: retry only what is idempotent. A retried POST that already succeeded but timed out on the way back charges the customer twice, which is what idempotency keys exist to prevent.

53 A timeout that actually cancels

hard

Exercise 23's timeout left the work running and the timer pending. Fix both: pass the work a cancellation token, clear the timer in a finally, and prove the abandoned work stops at its next check. Then show what happens to work that ignores the token.

Stuck? Three hints, smallest first
  1. 1. The token from exercise 33, plus the race from exercise 23. The timer cancels the token before it rejects.
  2. 2. Hold on to the running promise and attach a catch to it, or the loser becomes an unhandled rejection.
  3. 3. Clear the timer in a finally, which is what clearTimeout is for in real code.

The answer

function manualClock() {
  let now = 0;
  const timers = [];
  return {
    get time() { return now; },
    get pending() { return timers.length; },
    wait(ms) {
      const timer = { at: now + ms };
      const promise = new Promise((resolve) => { timer.resolve = resolve; });
      timers.push(timer);
      promise.clear = () => {                         // the clearTimeout handle
        const at = timers.indexOf(timer);
        if (at !== -1) timers.splice(at, 1);
      };
      return promise;
    },
    async advance(ms) {
      now += ms;
      for (const timer of timers.filter((t) => t.at <= now)) {
        timers.splice(timers.indexOf(timer), 1);
        timer.resolve();
      }
      for (let i = 0; i < 20; i++) await Promise.resolve();
    },
  };
}

class CancelledError extends Error {
  constructor(reason) { super(reason); this.name = 'CancelledError'; }
}

function cancellation() {
  let cancelled = false;
  let reason = '';
  return {
    get cancelled() { return cancelled; },
    get reason() { return reason; },
    throwIfCancelled() { if (cancelled) throw new CancelledError(reason); },
    cancel(why) { if (cancelled) return false; cancelled = true; reason = why; return true; },
  };
}

async function withDeadline(work, ms, clock) {
  const token = cancellation();
  const running = work(token);
  running.catch(() => {});                            // the loser must not go unhandled
  const timer = clock.wait(ms);
  const expired = timer.then(() => {
    token.cancel(`deadline of ${ms}ms passed`);
    throw new Error(`timed out after ${ms}ms`);
  });
  expired.catch(() => {});
  try {
    return await Promise.race([running, expired]);
  } finally {
    timer.clear();                                    // no timer left pending
    token.cancel('work finished');                    // no work left running
  }
}

const clock = manualClock();
const steps = [];
const payTills = async (token) => {
  for (const till of ['ikeja', 'yaba', 'lekki', 'ajah']) {
    token.throwIfCancelled();
    await clock.wait(100);
    steps.push(till);
  }
  return 'all four paid';
};

const outcome = withDeadline(payTills, 250, clock).then((v) => v, (err) => err.message);
await clock.advance(100);
await clock.advance(100);
const atTheDeadline = steps.slice();
await clock.advance(50);
const timedOut = await outcome;
await clock.advance(100);
const afterTheInFlightStep = steps.slice();
await clock.advance(500);
const nothingMore = steps.slice();
const timersLeft = clock.pending;

const second = manualClock();
const quickSteps = [];
const payOne = async (token) => {
  await second.wait(50);
  token.throwIfCancelled();
  quickSteps.push('paid');
  return 'paid in time';
};
const winner = withDeadline(payOne, 500, second).then((v) => v, (err) => err.message);
await second.advance(50);
const won = await winner;
const noLeak = second.pending;

const third = manualClock();
const stubbornSteps = [];
const ignoresTheToken = async () => {
  for (const till of ['ikeja', 'yaba', 'lekki']) {
    await third.wait(100);
    stubbornSteps.push(till);
  }
  return 'finished anyway';
};
const abandoned = withDeadline(ignoresTheToken, 150, third).then((v) => v, (err) => err.message);
await third.advance(100);
await third.advance(50);
const gaveUpAt = await abandoned;
await third.advance(100);
await third.advance(100);
const keptGoing = stubbornSteps.slice();

console.log('timed out      :', timedOut, 'with', atTheDeadline, 'done');
console.log('then one more  :', afterTheInFlightStep, '-> and stopped:', nothingMore);
console.log('work won       :', won, '- timers left:', noLeak);
console.log('token ignored  :', gaveUpAt, '- work did', keptGoing);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the caller gets the timeout at the deadline', timedOut, 'timed out after 250ms');
test('two steps had finished by then', atTheDeadline, ['ikeja', 'yaba']);
test('the step already in flight still lands, because nothing interrupts it', afterTheInFlightStep, ['ikeja', 'yaba', 'lekki']);
test('but the fourth never starts: the token stopped it, unlike exercise 23', nothingMore, ['ikeja', 'yaba', 'lekki']);
test('and no timer is left pending either', timersLeft, 0);
test('when the work wins, the caller gets its value', [won, quickSteps], ['paid in time', ['paid']]);
test('and the deadline timer is cleared rather than left to fire', noLeak, 0);
test('work that ignores the token cannot be stopped, only abandoned', [gaveUpAt, keptGoing], ['timed out after 150ms', ['ikeja', 'yaba', 'lekki']]);

This is exercise 23 with both leaks closed, and it takes two separate fixes because they are two separate leaks. timer.clear() in the finally is clearTimeout: without it a won race leaves a pending timer holding a closure, and in Node a pending timer also keeps the process alive. token.cancel() is the other half: the work is told to stop, so the fourth step never starts.

The third and fourth assertions are the honest limit, and they are the same granularity point exercise 33 makes. Cancellation is co-operative, so the step already awaiting its 100ms carries on to completion — you cannot un-send a request. What you can do is make sure nothing further is attempted, which is the difference between abandoning three units of work and abandoning all four.

The last assertion is what happens without co-operation, and it is the common case in real code: work that never checks the token runs to completion, produces a value nobody receives, and holds its connection the whole time. The caller cannot tell the difference — both cases reject with the same timeout — which is why the leak is invisible until the connection pool is exhausted. AbortSignal is the platform's version of this contract, and passing a signal to fetch works only because fetch was written to honour it.

One line that looks redundant and is not: running.catch(() => {}). When the timer wins, the work's own rejection has no handler attached, and an unhandled rejection in Node exits the process non-zero. Attaching an empty handler says "somebody has looked at this", which is exactly true — the race already reported the outcome the caller cares about.

54 Start everything, then await in order

hard

There is a third shape between await in a loop and Promise.all: start every task, then await them in array order. Show that it overlaps the work and still returns results in order — then find the rejection it leaves unattended, and the two ways to fix that.

Stuck? Three hints, smallest first
  1. 1. A promise starts when it is created, not when it is awaited. map first, await second.
  2. 2. Awaiting an already-settled promise costs a microtask and nothing else, so the loop order is free.
  3. 3. If the loop throws at index 1, nothing is ever attached to index 2.

The answer

const log = [];
const task = (name, rounds) => async () => {
  log.push('start ' + name);
  for (let i = 0; i < rounds; i++) await Promise.resolve();
  log.push('end ' + name);
  return name.toUpperCase();
};
const tasks = [task('a', 3), task('b', 1), task('c', 2)];

const oneAtATime = [];
for (const start of tasks) oneAtATime.push(await start());
const sequentialLog = log.slice();

log.length = 0;
const started = tasks.map((start) => start());
const startedAtOnce = log.slice();
const inOrder = [];
for (const running of started) inOrder.push(await running);
const overlappedLog = log.slice();

const viaAll = await Promise.all(started);
const nothingReran = log.slice();

let attempts = 0;
const fails = (name) => async () => { attempts++; await Promise.resolve(); throw new Error(name + ' failed'); };
const risky = [task('ok', 1)(), fails('b')(), fails('c')()];

let stoppedAt = '';
try { for (const running of risky) await running; }
catch (err) { stoppedAt = err.message; }

const handledLate = await risky[2].then(() => 'resolved', (err) => 'handled late: ' + err.message);
const everyOutcome = (await Promise.allSettled(risky)).map((r) => r.status);

console.log('one at a time :', sequentialLog);
console.log('all started   :', startedAtOnce);
console.log('then awaited  :', overlappedLog);
console.log('loop stopped  :', stoppedAt, '- index 2:', handledLate);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('awaiting inside the loop runs them strictly one at a time', sequentialLog, ['start a', 'end a', 'start b', 'end b', 'start c', 'end c']);
test('mapping first starts all three before anything is awaited', startedAtOnce, ['start a', 'start b', 'start c']);
test('so they finish by duration, not by position', overlappedLog.slice(3), ['end b', 'end c', 'end a']);
test('yet the results come back in array order', inOrder, ['A', 'B', 'C']);
test('Promise.all over the same promises gives the same values and starts nothing new', [viaAll, nothingReran.length], [['A', 'B', 'C'], 6]);
test('a rejection at index 1 ends the loop, leaving index 2 unattended', [stoppedAt, attempts], ['b failed', 2]);
test('that promise is still rejected, and can be handled later', handledLate, 'handled late: c failed');
test('allSettled is the shape that attaches a handler to every one', everyOutcome, ['fulfilled', 'rejected', 'rejected']);

The distinction that makes this work: a promise begins its work when it is created. tasks.map(start => start()) starts all three in one synchronous pass, and the awaits afterwards are just collection. You get the concurrency of Promise.all with results in array order and no wrapper — useful when you want to process each result as its turn comes rather than assembling one array at the end.

The sixth and seventh assertions are why this shape needs care. When index 1 rejects, the loop throws and index 2's promise is left with no handler at all — nobody ever awaited it. It is still rejected, and it can be handled later, but in Node an unhandled rejection that survives the tick exits the process non-zero, and in a browser it hits window.onunhandledrejection. So the failure mode is not a lost value, it is a crash somewhere unrelated.

Two fixes, both one line. Attach a catch when you start them — const p = start(); p.catch(() => {}); — if you intend to read the failures later. Or hand the whole array to Promise.all or allSettled, both of which attach a handler to every input immediately, which is the real reason they are the safer default. allSettled when each task stands alone, all when one failure voids the lot.

Worth noting what the fifth assertion proves: Promise.all over promises that have already been awaited does not re-run anything — the log is still six lines. A promise is a value, not a task, so awaiting it twice is free.

55 A disposal stack that finishes the job

hard

Exercise 43 released one resource. Now hold several: use(name, dispose) to register and disposeAll() to release them all in reverse order. A release that throws must not prevent the rest, and every failure must be reported — so collect them into an AggregateError.

Stuck? Three hints, smallest first
  1. 1. pop() in a loop gives you reverse order and empties the stack as it goes.
  2. 2. Wrap each release in its own try, or the first failure skips everything after it.
  3. 3. Wrap each failure with cause so the report says which resource, and still carries why.

The answer

function disposalStack() {
  const entries = [];
  return {
    use(name, dispose) { entries.push({ name, dispose }); return name; },
    get size() { return entries.length; },
    async disposeAll() {
      const failures = [];
      while (entries.length) {
        const { name, dispose } = entries.pop();          // reverse order, and it empties
        try { await dispose(); }
        catch (err) { failures.push(new Error('could not release ' + name, { cause: err })); }
      }
      if (failures.length) {
        throw new AggregateError(failures, failures.length + ' resource(s) failed to release');
      }
    },
  };
}

const released = [];
const clean = disposalStack();
clean.use('database connection', () => released.push('database connection'));
clean.use('temp file', async () => { await Promise.resolve(); released.push('temp file'); });
clean.use('till printer', () => released.push('till printer'));
const sizeBefore = clean.size;
const noComplaint = await clean.disposeAll().then(() => 'released cleanly', (err) => err.message);
const orderOut = released.slice();
const sizeAfter = clean.size;

const messy = disposalStack();
const attempted = [];
messy.use('database connection', () => { attempted.push('database connection'); });
messy.use('temp file', () => { attempted.push('temp file'); throw new Error('file already gone'); });
messy.use('till printer', async () => { attempted.push('till printer'); throw new Error('printer not responding'); });
const failure = await messy.disposeAll().catch((err) => err);
const reported = failure.errors.map((err) => err.message);
const rootCauses = failure.errors.map((err) => err.cause.message);

const twice = await messy.disposeAll().then(() => 'nothing left to do', (err) => err.message);

console.log('clean   :', noComplaint, orderOut);
console.log('messy   :', failure.message);
console.log('reported:', reported);
console.log('causes  :', rootCauses);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('everything is released, in reverse order of registration', orderOut, ['till printer', 'temp file', 'database connection']);
test('a clean run throws nothing', [sizeBefore, noComplaint, sizeAfter], [3, 'released cleanly', 0]);
test('a release that throws does not stop the ones behind it', attempted, ['till printer', 'temp file', 'database connection']);
test('every failure is reported, not just the first', reported, ['could not release till printer', 'could not release temp file']);
test('and each one names the resource while keeping the real reason', rootCauses, ['printer not responding', 'file already gone']);
test('the report is one AggregateError, so one catch handles it', [failure instanceof AggregateError, failure.message], [true, '2 resource(s) failed to release']);
test('the stack is emptied even when releases failed', messy.size, 0);
test('so disposing twice is a harmless no-op', twice, 'nothing left to do');

Cleanup code is the last place you want a single failure to be decisive, and the third assertion is the whole exercise: the printer failed first and the database connection was still closed. The naive loop — one try around the whole thing — closes resources until something throws and then abandons the rest, which is how a failed file delete leaks a connection.

So each release gets its own try, and the failures are collected rather than thrown. That leaves the question of what to do with a list of failures, and AggregateError from exercise 36 is the answer: one throw, one catch for the caller, every reason preserved. Wrapping each one with cause is what makes the report readable — "could not release till printer" says which, and the cause says why, and neither would be recoverable from the other alone.

Reverse order is not decoration. Resources acquired in order usually depend on each other, so a transaction must be closed before its connection and a stream flushed before its file handle. pop() gives that ordering for free and empties the stack as it goes, which is what makes the last two assertions true: nothing is left registered even after a failure, so a second disposeAll does nothing rather than double-releasing.

This is the shape the language is standardising as AsyncDisposableStack, with await using and Symbol.asyncDispose handling the registration and the finally for you. The semantics are the ones here — reverse order, all releases attempted, failures aggregated — so writing it once is the fastest way to understand what the syntax will be doing on your behalf.

56 A Proxy that gives an array negative indices

hard

xs[-1] is undefined, because -1 becomes the property key "-1". Wrap an array in a Proxy whose get trap makes negative indices count back from the end, while length, join, spread and push all still work. Then find out what assigning to [-1] does.

Stuck? Three hints, smallest first
  1. 1. A property key is always a string or a symbol, so the trap receives "-1", not -1.
  2. 2. Everything you do not handle must be forwarded: Reflect.get(target, key, receiver).
  3. 3. You trapped reads only. An assignment goes through set, which you did not trap.

The answer

const fromEnd = (array) => new Proxy(array, {
  get(target, key, receiver) {
    if (typeof key === 'string' && /^-\d+$/.test(key)) {
      const at = target.length + Number(key);
      return at >= 0 ? target[at] : undefined;
    }
    return Reflect.get(target, key, receiver);   // everything else, untouched
  },
});

const base = ['Yaba', 'Surulere', 'Ojuelegba', 'Ikeja'];
const stops = fromEnd(base);

const negatives = [stops[-1], stops[-2]];
const outOfRange = stops[-9] === undefined;
const positives = [stops[0], stops.length];
const joined = stops.join(' - ');
const spread = [...stops];
const stillAnArray = Array.isArray(stops);

stops.push('Agege');                  // push reads length and writes through the untrapped set
const afterPush = [base.length, stops[-1]];

stops[-1] = 'Mushin';                 // NOT the last element: a property literally named "-1"
const lengthUnchanged = base.length;
const literalKey = Object.hasOwn(base, '-1');
const readBack = stops[-1];
const onTheTarget = base['-1'];

console.log('negatives :', negatives);
console.log('joined    :', joined);
console.log('after push:', afterPush);
console.log('base      :', base, 'plus a "-1" key holding', onTheTarget);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('-1 reads the last element and -2 the one before it', negatives, ['Ikeja', 'Ojuelegba']);
test('a negative index past the start is undefined, not an error', outOfRange, true);
test('positive indices and length are forwarded unchanged', positives, ['Yaba', 4]);
test('so array methods still work, because they read through the same trap', [joined, spread.length], ['Yaba - Surulere - Ojuelegba - Ikeja', 4]);
test('Array.isArray is true: the target is a real array, not an impostor', stillAnArray, true);
test('push mutates the target and the trap sees the new end', afterPush, [5, 'Agege']);
test('assigning to [-1] adds a property named "-1" and leaves length alone', [lengthUnchanged, literalKey], [5, true]);
test('and the get trap now shadows it, so that value is unreachable through the proxy', [readBack, onTheTarget], ['Agege', 'Mushin']);

The reason xs[-1] does not work is not that arrays refuse negative numbers — it is that there are no numbers in property keys at all. -1 is converted to the string "-1", which is not an array index, so it is an ordinary property that happens to be absent. The trap works because it can recognise that string and compute a real index.

Two details separate a working proxy from a broken one. The first is the fall-through: Reflect.get(target, key, receiver) rather than target[key], so length, join, Symbol.iterator and every method still resolve, and a getter further up the chain runs against the right receiver. Forget it and the proxy answers undefined for everything you did not think of.

The second is the last two assertions, and they are the trap. get intercepts reads only. stops[-1] = 'Mushin' goes through set, which is untrapped, so it lands on the target as a genuine property called "-1" — invisible to length, invisible to JSON.stringify on an array, and then permanently shadowed by the very trap that made reads work. A symmetrical proxy has to trap set, has and deleteProperty the same way, which is why Array.prototype.at exists and this does not ship in real code.

57 An object with a default value

fair

Build withDefault(fallback, seed): a proxy where a missing key reads as fallback instead of undefined, so a tally needs no ?? 0. Then check whether in, Object.keys and JSON.stringify agree with the trap.

Stuck? Three hints, smallest first
  1. 1. Reflect.has(target, key) tells you whether the key is really there before you invent a value.
  2. 2. The get trap does not create anything, so nothing else about the object changed.
  3. 3. Trapping has as well makes in answer true — for every key, including nonsense.

The answer

const withDefault = (fallback, seed = {}) => new Proxy({ ...seed }, {
  get(target, key, receiver) {
    return Reflect.has(target, key) ? Reflect.get(target, key, receiver) : fallback;
  },
});

const fares = withDefault(0, { Yaba: 300, Ikeja: 500 });

const seeded = fares.Yaba;
const missing = fares.Lekki;
const tallied = fares.Lekki + fares.Ikeja + 100;      // no ?? 0 anywhere
const inSaysNo = 'Lekki' in fares;
const realKeys = Object.keys(fares);

fares.Lekki = 700;                                     // set is untrapped, so this is real
const nowPresent = ['Lekki' in fares, fares.Lekki];
const serialised = JSON.parse(JSON.stringify(fares));

// The same idea with `has` trapped too, which is a much bigger claim.
const everything = new Proxy({ Yaba: 300, Ikeja: 500 }, {
  get: (t, k, r) => (Reflect.has(t, k) ? Reflect.get(t, k, r) : 0),
  has: () => true,
});
const inSaysYes = ['Ojota' in everything, 'nonsense' in everything];
const keysUnmoved = Object.keys(everything);

console.log('seeded, missing:', seeded, missing);
console.log('tallied        :', tallied);
console.log('real keys      :', realKeys);
console.log('serialised     :', serialised);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('a missing key reads as the fallback', [seeded, missing], [300, 0]);
test('so arithmetic over unknown stops needs no guard', tallied, 600);
test('but `in` still says the key is absent: get invents, it does not create', inSaysNo, false);
test('and Object.keys lists only what is really there', realKeys, ['Yaba', 'Ikeja']);
test('assigning through it creates a real key, and then `in` agrees', nowPresent, [true, 700]);
test('serialising shows the real keys only, never the default', serialised, { Yaba: 300, Ikeja: 500, Lekki: 700 });
test('a has trap makes `in` answer true for anything at all', inSaysYes, [true, true]);
test('yet ownKeys is untrapped, so Object.keys is unchanged by that lie', keysUnmoved, ['Yaba', 'Ikeja']);

{ ...seed } rather than seed is deliberate: a proxy wraps a target it does not own, and handing it the caller's object means writes through the proxy mutate their object. Copying makes the wrapper self-contained.

The interesting part is where the illusion stops. A get trap answers reads and nothing else, so the object still has two keys: in says no, Object.keys says no, JSON.stringify says no. That is usually what you want — a tally that reads as 0 for every Lagos stop but serialises only the ones with fares.

Trapping has too is where it gets dangerous. 'nonsense' in everything now answers true, and any code doing feature detection — if ('toJSON' in obj), if ('then' in obj) — is now being lied to. The last assertion shows how inconsistent that leaves the object: in claims every key exists while Object.keys lists two, because those go through different traps. Traps are independent, and a proxy is only as coherent as you make it.

One quiet hazard with any get trap: JSON.stringify asks for toJSON first. A fallback of 0 is not callable so it is ignored, but a fallback that happened to be a function would be called as toJSON and would decide the whole serialisation.

58 Validation on assignment, and the way round it

hard

Wrap an order object so every write is validated: an unknown field is refused and a bad value throws with the field named. Then prove two things — that a set trap returning false throws in strict mode, and that Object.defineProperty walks straight past a set trap unless you trap that too.

Stuck? Three hints, smallest first
  1. 1. The set trap must return true when it accepts the write. Returning nothing counts as a refusal.
  2. 2. Reflect.set(target, key, value, receiver) both performs the write and gives you the boolean to return.
  3. 3. Object.defineProperty is a different internal operation. It has its own trap.

The answer

const rules = {
  customer: (v) => (typeof v === 'string' && v.trim() !== '' ? '' : 'must be a name'),
  amount: (v) => (Number.isInteger(v) && v > 0 ? '' : 'must be a positive whole number of naira'),
};
const check = (key, value) => {
  const rule = rules[key];
  if (!rule) throw new TypeError('no such field: ' + String(key));
  const complaint = rule(value);
  if (complaint) throw new TypeError(String(key) + ' ' + complaint);
};

const validated = (target) => new Proxy(target, {
  set(t, key, value, receiver) {
    check(key, value);
    return Reflect.set(t, key, value, receiver);      // returns true, which the trap must
  },
  defineProperty(t, key, desc) {
    if ('value' in desc) check(key, desc.value);      // or defineProperty slips past set
    return Reflect.defineProperty(t, key, desc);
  },
});

const attempt = (fn) => { try { fn(); return 'no error'; } catch (err) { return err.message; } };
const attemptName = (fn) => { try { fn(); return 'no error'; } catch (err) { return err.name; } };

const raw = {};
const order = validated(raw);
order.customer = 'Adaeze';
order.amount = 25000;
const accepted = { ...order };

const badAmount = attempt(() => { order.amount = -500; });
const badName = attempt(() => { order.customer = '   '; });
const unknownField = attempt(() => { order.status = 'paid'; });
const targetAfter = { ...raw };

const viaDefine = attempt(() => Object.defineProperty(order, 'amount', { value: -999 }));

// A trap that refuses by returning false rather than throwing.
const refuses = new Proxy({}, { set: () => false });
const strictWrite = attemptName(() => { refuses.anything = 1; });
const reflectWrite = Reflect.set(refuses, 'anything', 1);

// The same object with only a set trap: defineProperty is not checked at all.
const setOnly = new Proxy({}, { set() { throw new Error('no writes allowed'); } });
Object.defineProperty(setOnly, 'amount', { value: -999, enumerable: true, configurable: true });
const slippedThrough = setOnly.amount;

console.log('accepted   :', accepted);
console.log('rejections :', [badAmount, badName, unknownField]);
console.log('defineProp :', viaDefine, '| unguarded:', slippedThrough);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('valid writes land on the target', [accepted, targetAfter], [{ customer: 'Adaeze', amount: 25000 }, { customer: 'Adaeze', amount: 25000 }]);
test('a bad value throws with the field named', [badAmount, badName], ['amount must be a positive whole number of naira', 'customer must be a name']);
test('a field with no rule is refused rather than silently accepted', unknownField, 'no such field: status');
test('and nothing rejected reached the object', Object.keys(targetAfter), ['customer', 'amount']);
test('trapping defineProperty closes the second door in', viaDefine, 'amount must be a positive whole number of naira');
test('with only a set trap, defineProperty writes -999 straight past it', slippedThrough, -999);
test('a set trap returning false throws a TypeError in strict mode', strictWrite, 'TypeError');
test('while Reflect.set reports the same refusal as a boolean', reflectWrite, false);

Two doors lead into an object, and a validator that guards one is not a validator. obj.k = v goes through [[Set]]; Object.defineProperty and Object.assign-style descriptor writes go through [[DefineOwnProperty]]. The sixth assertion is the whole point: a proxy with a set trap that throws on every write accepted -999 without complaint, because defineProperty never asked it.

The return true is not ceremony. A set trap reports success as a boolean, and a trap that falls off the end returns undefined, which reads as a refusal. Under "use strict" — which every module, every class body and every panel on this page is — a refused write is a TypeError, not a silent no-op. That is the pairing in the last two assertions: refuses.anything = 1 throws, Reflect.set(refuses, 'anything', 1) returns false. Same operation, one reports by throwing and one by value, which is exactly why Reflect exists.

Throwing from inside the trap, as this one does for a bad value, is usually kinder than returning false: the caller gets a message naming the field instead of an engine-worded TypeError saying a trap returned something falsish.

59 A read-only view over a mutable object

hard

Hand a caller a view of your configuration that they cannot write to, delete from or redefine — while you keep writing to the original. Make it deep, so a nested object read through the view is read-only too. Then find the two things this is not: a freeze, and a stable identity.

Stuck? Three hints, smallest first
  1. 1. Three traps refuse by returning false: set, deleteProperty and defineProperty.
  2. 2. For depth, the get trap has to wrap any object it is about to hand back.
  3. 3. Wrapping on every read means a fresh proxy every time. Compare two reads of the same key with ===.

The answer

const readOnly = (target) => new Proxy(target, {
  get(t, key, receiver) {
    const value = Reflect.get(t, key, receiver);
    return value !== null && typeof value === 'object' ? readOnly(value) : value;
  },
  set: () => false,
  deleteProperty: () => false,
  defineProperty: () => false,
  setPrototypeOf: () => false,
});

const attemptName = (fn) => { try { fn(); return 'no error'; } catch (err) { return err.name; } };

const settings = {
  till: 'Balogun 3',
  limits: { perOrder: 500000, daily: 2000000 },
};
const view = readOnly(settings);

const reads = [view.till, view.limits.perOrder];
const writeAttempt = attemptName(() => { view.till = 'Ikeja 1'; });
const deleteAttempt = attemptName(() => { delete view.till; });
const defineAttempt = attemptName(() => Object.defineProperty(view, 'till', { value: 'Ikeja 1' }));
const reflectSays = [Reflect.set(view, 'till', 'Ikeja 1'), Reflect.deleteProperty(view, 'till')];
const untouched = settings.till;

const nestedWrite = attemptName(() => { view.limits.daily = 0; });
const nestedUntouched = settings.limits.daily;

settings.limits.daily = 3000000;                 // the owner still writes to the original
const viewSeesIt = view.limits.daily;
const looksFrozen = [Object.isFrozen(view), Object.isFrozen(settings)];
const sameTwice = view.limits === view.limits;

console.log('reads      :', reads);
console.log('refusals   :', [writeAttempt, deleteAttempt, defineAttempt, nestedWrite]);
console.log('view sees  :', viewSeesIt, '| identity stable:', sameTwice);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('reading through the view works normally', reads, ['Balogun 3', 500000]);
test('every kind of write throws a TypeError in strict mode', [writeAttempt, deleteAttempt, defineAttempt], ['TypeError', 'TypeError', 'TypeError']);
test('Reflect reports the same refusals as false instead of throwing', reflectSays, [false, false]);
test('and the original is untouched by any of it', untouched, 'Balogun 3');
test('a nested object read through the view is read-only too', [nestedWrite, nestedUntouched], ['TypeError', 2000000]);
test('but the owner still writes to the original, and the view shows it', viewSeesIt, 3000000);
test('so this is not a freeze: neither object is frozen', looksFrozen, [false, false]);
test('and wrapping on every read costs identity: two reads are two proxies', sameTwice, false);

A read-only view and Object.freeze solve different problems. Freezing makes the object immutable for everyone, including you. A view removes one reference's ability to write while the holder of the original carries on — which is what you want when handing configuration to a plugin, or a cache's contents to a caller who must not corrupt it. The sixth and seventh assertions are the difference stated plainly: the daily limit changed to 3,000,000 through the original and the view reported it, and Object.isFrozen is false on both.

Depth costs something, and the last assertion is the bill. Because get wraps each object as it is handed out, two reads of view.limits produce two different proxies, so === fails, a Map keyed on it gets two entries, and a React-style identity check sees a change on every render. Caching the wrappers in a WeakMap keyed by target fixes it and is the version worth shipping.

setPrototypeOf is in the handler for the same reason as the other three: without it a caller can replace the prototype and shadow behaviour without ever writing an own property. What still leaks through: a method reached via the view runs with the view as this, so a mutating method throws rather than silently failing — better, but it is a runtime error in someone else's code, so a view is a safety net, not an API.

60 An access log, and the reads you did not know you made

fair

Trap get, set, has, deleteProperty and ownKeys to record every operation on an object. Then run spread and JSON.stringify over it and account for every line in the log, including the ones you did not write.

Stuck? Three hints, smallest first
  1. 1. Record the operation and the key, then forward through the matching Reflect method.
  2. 2. Spread is not one operation: it asks for the keys first, then reads each one.
  3. 3. JSON.stringify asks the object for something before it asks for any of your data.

The answer

const log = [];
const watch = (target) => new Proxy(target, {
  get(t, k, r) { log.push('get ' + String(k)); return Reflect.get(t, k, r); },
  set(t, k, v, r) { log.push('set ' + String(k)); return Reflect.set(t, k, v, r); },
  has(t, k) { log.push('has ' + String(k)); return Reflect.has(t, k); },
  deleteProperty(t, k) { log.push('delete ' + String(k)); return Reflect.deleteProperty(t, k); },
  ownKeys(t) { log.push('ownKeys'); return Reflect.ownKeys(t); },
});

const trip = watch({ stop: 'Ojota', fare: 400 });

trip.stop;
trip.fare = 450;
const asked = 'fare' in trip;
const gone = delete trip.stop;
const afterFour = log.slice();

log.length = 0;
const keys = Object.keys(trip);
const afterKeys = log.slice();

log.length = 0;
const copy = { ...trip };
const afterSpread = log.slice();

log.length = 0;
const json = JSON.stringify(trip);
const afterJson = log.slice();

console.log('four operations:', afterFour);
console.log('Object.keys    :', afterKeys);
console.log('spread         :', afterSpread);
console.log('stringify      :', afterJson, '->', json);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the four operations you wrote are logged in order', afterFour, ['get stop', 'set fare', 'has fare', 'delete stop']);
test('and they did what they normally do', [asked, gone, keys], [true, true, ['fare']]);
test('Object.keys is one ownKeys, with no reads at all', afterKeys, ['ownKeys']);
test('spread is two operations: ask for the keys, then read each one', afterSpread, ['ownKeys', 'get fare']);
test('so the copy is a plain object with the same data', copy, { fare: 450 });
test('JSON.stringify asks for toJSON before it asks for anything of yours', afterJson[0], 'get toJSON');
test('then it does the same keys-then-read pass', afterJson.slice(1), ['ownKeys', 'get fare']);
test('and the result is what you would expect from the data', json, '{"fare":450}');

The log is the lesson: high-level syntax decomposes into low-level operations, and a proxy is the only way to watch it happen. Spread is not a copy primitive, it is [[OwnPropertyKeys]] followed by a [[Get]] per key — which is why spreading an object with a getter runs the getter, and why spreading a proxy with an expensive get trap is expensive per key rather than once.

JSON.stringify is the one that surprises people. Its first act is get toJSON, on your object, before it has looked at a single key. If a get trap answers that with anything callable, that function decides the entire output. It is also why a logging proxy over a domain object shows a toJSON read that appears nowhere in the codebase.

Object.keys logs one ownKeys and no reads, because it wants names, not values. It does also perform a [[GetOwnProperty]] per key to check enumerability — untrapped here, so it goes straight to the target and leaves no line in the log. Exercise 61 is what happens when ownKeys and that check disagree.

A note on String(k) in each trap: a key can be a symbol, and string-concatenating a symbol throws. Logging 'get ' + k would crash the moment anything read Symbol.iterator or Symbol.toPrimitive off the object — which spread and await both do.

61 ownKeys has to agree with getOwnPropertyDescriptor

hard

Write an ownKeys trap that invents a key the target does not have. Find out why Reflect.ownKeys lists it and Object.keys does not, then make them agree. Then break three of the invariants the engine enforces and confirm each one throws.

Stuck? Three hints, smallest first
  1. 1. Object.keys filters the reported keys by asking for each one's descriptor, and drops any that has none.
  2. 2. So the fix is a getOwnPropertyDescriptor trap that describes the invented key.
  3. 3. The engine refuses three things outright: duplicates, hiding a non-configurable key, and inventing a key on a non-extensible target.

The answer

const attemptName = (fn) => { try { fn(); return 'no error'; } catch (err) { return err.name; } };

const base = { stop: 'Ojota' };

// An invented key, reported by ownKeys but described by nobody.
const halfDone = new Proxy(base, {
  ownKeys: (t) => [...Reflect.ownKeys(t), 'zone'],
});
const reflectSees = Reflect.ownKeys(halfDone);
const objectKeysSees = Object.keys(halfDone);
const spreadSees = Object.keys({ ...halfDone });

// The same trap, with a descriptor for the invented key.
const consistent = new Proxy(base, {
  ownKeys: (t) => [...Reflect.ownKeys(t), 'zone'],
  getOwnPropertyDescriptor(t, key) {
    if (key === 'zone') return { value: 'Lagos Mainland', enumerable: true, configurable: true };
    return Reflect.getOwnPropertyDescriptor(t, key);
  },
  get(t, key, r) { return key === 'zone' ? 'Lagos Mainland' : Reflect.get(t, key, r); },
});
const nowKeys = Object.keys(consistent);
const nowSpread = { ...consistent };

// Invariant 1: no duplicates.
const duplicated = new Proxy({}, { ownKeys: () => ['stop', 'stop'] });
const duplicateError = attemptName(() => Reflect.ownKeys(duplicated));

// Invariant 2: a non-configurable own key cannot be hidden.
const pinned = {};
Object.defineProperty(pinned, 'id', { value: 'LAG-0061', configurable: false, enumerable: true });
const hiding = new Proxy(pinned, { ownKeys: () => [] });
const hidingError = attemptName(() => Reflect.ownKeys(hiding));

// Invariant 3: nothing may be invented on a non-extensible target.
const sealedTarget = Object.preventExtensions({ stop: 'Ojota' });
const inventing = new Proxy(sealedTarget, { ownKeys: (t) => [...Reflect.ownKeys(t), 'zone'] });
const inventingError = attemptName(() => Reflect.ownKeys(inventing));

console.log('Reflect vs Object.keys:', reflectSees, objectKeysSees);
console.log('once consistent       :', nowKeys, nowSpread);
console.log('invariant errors      :', [duplicateError, hidingError, inventingError]);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('Reflect.ownKeys reports exactly what the trap returned', reflectSees, ['stop', 'zone']);
test('Object.keys drops the invented key, because nothing describes it', objectKeysSees, ['stop']);
test('and spread drops it for the same reason', spreadSees, ['stop']);
test('adding a getOwnPropertyDescriptor trap makes them agree', nowKeys, ['stop', 'zone']);
test('and then the key is copied out with its value', nowSpread, { stop: 'Ojota', zone: 'Lagos Mainland' });
test('a duplicate key in ownKeys is a TypeError', duplicateError, 'TypeError');
test('so is hiding a non-configurable own key', hidingError, 'TypeError');
test('so is inventing a key on a non-extensible target', inventingError, 'TypeError');

Object.keys is not ownKeys. It calls [[OwnPropertyKeys]] and then, for each string key, asks [[GetOwnProperty]] whether that property is enumerable. A key with no descriptor is not enumerable, so it is filtered out — which is why the first proxy is listed one way by Reflect.ownKeys and another by everything a normal program uses. Two traps, one story: report a key and you must describe it.

The invariants are the part worth internalising, because they are the boundary of what a proxy is allowed to lie about. A proxy may not contradict facts a caller could already have relied on: something told a key is non-configurable can never be told it is gone, and something told an object is non-extensible can never be shown a new key. Break either and the engine throws a TypeError at the caller, from a line that looks entirely innocent. Duplicates are rejected outright because the result is meant to be a set of keys.

One more invariant lurks in the descriptor trap above: it reports configurable: true. Reporting a property as configurable: false when the target has no such property is also a TypeError, for the same reason — it is a promise about an object that cannot keep it. When in doubt, describe invented properties as configurable and enumerable, and let the target speak for its own.

62 A get trap that fires for `then` and breaks await

hard

await x reads x.then. So a proxy with a catch-all get trap can accidentally become a thenable. Show that awaiting a proxy whose trap returns a non-function is harmless, that a trap returning a function for every key makes the promise machinery call then, and that the promise then never settles.

Stuck? Three hints, smallest first
  1. 1. Anything is a thenable if typeof x.then === 'function'. Nothing else is checked.
  2. 2. Promise.resolve(thenable) calls then(resolve, reject) on it in a microtask.
  3. 3. Do not await that promise. Race it against one that settles, or the panel waits for ever.

The answer

const thenCalls = [];

// A trap that answers every unknown key with a string. 'then' is a string, so
// the proxy is not a thenable, and await hands it straight back.
const withString = new Proxy({ receipt: 'LAG-0062' }, {
  get: (t, k, r) => (Reflect.has(t, k) ? Reflect.get(t, k, r) : 'unknown field'),
});
const awaited = await withString;
const sameObject = awaited === withString;
const readable = awaited.receipt;
const thenIsAString = typeof withString.then;

// The same trap, answering with a function. Now it looks like a promise.
const withFunction = new Proxy({ receipt: 'LAG-0062' }, {
  get: (t, k, r) => (Reflect.has(t, k) ? Reflect.get(t, k, r)
    : (...args) => { thenCalls.push([String(k), args.length, typeof args[0], typeof args[1]]); }),
});
const looksThenable = typeof withFunction.then === 'function';
thenCalls.length = 0;

const adopted = Promise.resolve(withFunction);      // NOT awaited: it can never settle
await Promise.resolve();                            // let the thenable job run
await Promise.resolve();
const whatThenGot = thenCalls.slice();
const raced = await Promise.race([adopted.then(() => 'settled'), Promise.resolve('still pending')]);

// The fix: answer 'then' honestly, whatever else the trap invents.
const safe = new Proxy({ receipt: 'LAG-0062' }, {
  get(t, k, r) {
    if (k === 'then' || k === 'catch' || k === 'finally') return Reflect.get(t, k, r);
    return Reflect.has(t, k) ? Reflect.get(t, k, r) : 'unknown field';
  },
});
const safeAwait = await safe;
const safeIsSame = [safeAwait === safe, safeAwait.receipt, typeof safe.then];

console.log('await a non-thenable proxy:', sameObject, readable);
console.log('then was called with      :', whatThenGot);
console.log('race says                 :', raced);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('a trap returning a non-function for then leaves the proxy non-thenable', thenIsAString, 'string');
test('so await gives back the very same object', [sameObject, readable], [true, 'LAG-0062']);
test('a trap returning a function for every key makes it look like a promise', looksThenable, true);
test('and the machinery calls then, handing it resolve and reject', whatThenGot, [['then', 2, 'function', 'function']]);
test('that then resolves nothing, so the promise never settles', raced, 'still pending');
test('answering then from the target only keeps await working', safeIsSame, [true, 'LAG-0062', 'undefined']);
test('nothing else about the trap changed: unknown keys still get the default', safe.zone, 'unknown field');
test('and thenCalls proves the safe proxy was never asked to be a promise', thenCalls.length, 1);

There is no interface to implement and no marker to set. await and then ask one question — is x.then callable — and any object that answers yes is treated as a promise. A catch-all get trap answers yes by accident, and the failure is the worst kind: await proxy hangs for ever with no error, no stack and nothing logged.

The fourth assertion shows what the runtime actually does. It calls then.call(proxy, resolve, reject) in a microtask and waits. A default handler that ignores both arguments never calls either one, so the promise stays pending until the process exits. That is why the race in the fifth assertion is here at all: it is the only way to assert "never settles" without hanging the build.

Two rules follow. If you write a catch-all trap, exempt then — and while you are there, catch, finally and Symbol.toPrimitive, which the same accident affects. And when a default value must be invented, prefer undefined or a non-callable: it is the callability that does the damage. The same trap bites plain objects too, which is why an ORM row or a mock whose every property is a stub function should never be returned from an async function.

63 What a Proxy cannot intercept

hard

A proxy traps property operations, and nothing else. Prove it: put a bare proxy around a Map and call get on it; read a private field through a proxy of a class instance; then check what typeof and === report. Fix the Map case twice — once for its methods, once for size — and note what the fix costs.

Stuck? Three hints, smallest first
  1. 1. A Map keeps its data in an internal slot, not in properties. Map.prototype.get looks for that slot on this.
  2. 2. A method reached through a proxy is called with the proxy as this. Bind it to the target instead — and remember an accessor has the same problem.
  3. 3. There is no trap for typeof, none for ===, and a #field is not a property key.

The answer

const attemptName = (fn) => { try { fn(); return 'no error'; } catch (err) { return err.name; } };

const fares = new Map([['Yaba', 300], ['Ikeja', 500]]);
const bare = new Proxy(fares, {});
const slotError = attemptName(() => bare.get('Yaba'));

// The usual fix: hand back methods already bound to the target.
const bound = new Proxy(fares, {
  get(t, key, receiver) {
    const value = Reflect.get(t, key, receiver);
    return typeof value === 'function' ? value.bind(t) : value;
  },
});
const worksNow = bound.get('Yaba');
bound.set('Ojota', 250);
const targetChanged = fares.get('Ojota');
const identityLost = bound.get === bound.get;
const accessorStillFails = attemptName(() => bound.size);   // size is a getter, not a method

// Forwarding the target as the RECEIVER too is what fixes the accessor.
const forwarded = new Proxy(fares, {
  get(t, key) {
    const value = Reflect.get(t, key, t);
    return typeof value === 'function' ? value.bind(t) : value;
  },
});
const sizeWorks = forwarded.size;

class Account {
  #balance = 0;
  constructor(name) { this.name = name; }
  deposit(naira) { this.#balance += naira; return this.#balance; }
  get balance() { return this.#balance; }
}
const account = new Account('Adaeze');
account.deposit(15000);
const proxied = new Proxy(account, {});
const privateError = attemptName(() => proxied.balance);
const publicFine = proxied.name;
const noSuchKey = [Object.getOwnPropertyNames(account), Object.keys(account)];

const identity = [proxied === account, bare === fares];
const typeofSays = [typeof new Proxy({}, {}), typeof new Proxy(function () {}, {})];
const stillAnInstance = [proxied instanceof Account, bare instanceof Map];

console.log('Map behind a bare proxy:', slotError, '| bound:', worksNow, '| size:', accessorStillFails);
console.log('private field          :', privateError, '| public:', publicFine);
console.log('typeof                 :', typeofSays);

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('a Map method called through a bare proxy throws: the slot is not on the proxy', slotError, 'TypeError');
test('binding the method to the target fixes reads and writes', [worksNow, targetChanged], [300, 250]);
test('but size is a getter, not a method, so binding does nothing for it', accessorStillFails, 'TypeError');
test('forwarding the target as the receiver as well is what fixes the accessor', sizeWorks, 3);
test('and every read hands back a new bound function, so method identity breaks', identityLost, false);
test('a private field read through a proxy throws for the same reason a Map does', [privateError, publicFine], ['TypeError', 'Adaeze']);
test('a #field is not a property key, so nothing can list it or trap it', noSuchKey, [['name'], ['name']]);
test('identity cannot be trapped, and neither can typeof', [identity, typeofSays], [[false, false], ['object', 'function']]);
test('instanceof does work, because it goes through getPrototypeOf, which is a trap', stillAnInstance, [true, true]);

The mental model that fails here is "a proxy is a transparent wrapper". It is not: it is an object with the same property operations and a different identity. Anything the language does not express as a property operation goes straight past it.

Map, Set, Date, Promise, typed arrays and every private field are the cases you meet in practice, and they fail the same way. Map.prototype.get begins by checking that this has the [[MapData]] slot; the proxy is a different object and has no slots of its own, so it throws. A private #balance is worse: it is not keyed by name at all, so no trap could intercept it, and the sixth assertion shows it is invisible to getOwnPropertyNames as well.

The binding fix is incomplete, and the third assertion is where it shows. size is an accessor on Map.prototype, not a method, so there is nothing to hand back and bind — the getter runs during the read, against whatever receiver was passed. Forwarding Reflect.get(target, key, target) rather than the proxy fixes it, at the price exercise 64 warns about: an inherited getter now reads the target instead of the object the read started from.

The binding fix is also not free. Every read of bound.get allocates a new bound function, so method identity fails — removeEventListener with a handler read through such a proxy will not find it, and a WeakMap keyed on the method gets a fresh entry each time. Cache the bound methods if you need this in earnest. Binding also re-attaches this to the target, which means mutations you meant to observe now happen behind the proxy's back.

typeof, ===, Object.is and the internals of await's resolution are not property operations and have no traps. instanceof is the interesting exception: it reads the prototype, which is a property operation, so it works — and can be redirected, as exercise 67 does with Symbol.hasInstance.

64 Reflect, and the receiver argument that matters

fair

Reflect is the plain-function form of every operation a trap can intercept. Use Reflect.get and Reflect.set with an explicit receiver to run a getter against a different object, list a symbol and a non-enumerable key with Reflect.ownKeys, and compare how Reflect and Object report a refusal.

Stuck? Three hints, smallest first
  1. 1. The third argument to Reflect.get becomes this inside a getter.
  2. 2. Reflect.set(target, key, value, receiver) writes onto the receiver, not the target.
  3. 3. Object.defineProperty throws where Reflect.defineProperty returns false.

The answer

const attemptName = (fn) => { try { fn(); return 'no error'; } catch (err) { return err.name; } };

const stallPrototype = {
  get label() { return this.item + ' by the ' + this.unit; },
};
const yam = { item: 'yam', unit: 'tuber' };
const rice = Object.create(stallPrototype);
rice.item = 'rice';
rice.unit = 'kg';

const own = Reflect.get(rice, 'item');
const throughChain = Reflect.get(rice, 'label');
const withOtherReceiver = Reflect.get(stallPrototype, 'label', yam);

const source = { fare: 400 };
const receiver = {};
const setReported = Reflect.set(source, 'fare', 450, receiver);
const whereItLanded = [source.fare, receiver.fare];

const AUDIT = Symbol('audit');
const order = { customer: 'Bisi', [AUDIT]: 'Balogun till 3' };
Object.defineProperty(order, 'internalRef', { value: 'LAG-0064', enumerable: false });
const objectKeys = Object.keys(order);
const allKeys = Reflect.ownKeys(order).map(String);
const names = Object.getOwnPropertyNames(order);

const frozen = Object.freeze({ fare: 400 });
const reflectRefuses = Reflect.defineProperty(frozen, 'fare', { value: 500 });
const objectThrows = attemptName(() => Object.defineProperty(frozen, 'fare', { value: 500 }));
const deleteRefuses = Reflect.deleteProperty(frozen, 'fare');
const deleteThrows = attemptName(() => { delete frozen.fare; });

const hasAgrees = [Reflect.has(rice, 'label'), 'label' in rice, Reflect.has(rice, 'zone')];

console.log('getter with another receiver:', withOtherReceiver);
console.log('set landed on               :', whereItLanded);
console.log('keys                        :', objectKeys, 'vs', allKeys);

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('Reflect.get with no receiver is ordinary property access', [own, throughChain], ['rice', 'rice by the kg']);
test('a receiver runs the inherited getter against a different object', withOtherReceiver, 'yam by the tuber');
test('Reflect.set reports success and writes onto the receiver', [setReported, whereItLanded], [true, [400, 450]]);
test('Object.keys hides both the symbol and the non-enumerable key', objectKeys, ['customer']);
test('Reflect.ownKeys hides neither', allKeys, ['customer', 'internalRef', 'Symbol(audit)']);
test('getOwnPropertyNames sees the non-enumerable one but never symbols', names, ['customer', 'internalRef']);
test('Reflect reports a refused define or delete as false', [reflectRefuses, deleteRefuses], [false, false]);
test('where the Object form and the delete operator throw in strict mode', [objectThrows, deleteThrows], ['TypeError', 'TypeError']);
test('Reflect.has is exactly the `in` operator, chain included', hasAgrees, [true, true, false]);

Reflect exists so a trap can do its default job. Every method matches a trap one-for-one and takes the arguments the trap receives, which is why forwarding is Reflect.get(target, key, receiver) and not target[key].

The receiver is the argument people drop, and the second assertion is what it costs. A getter on the prototype reads this.item, so this has to be the object the read started from. Forward without the receiver and the getter runs against the target instead — on a proxy that means every inherited getter and setter silently reads and writes the wrong object, a bug that only appears once someone adds a getter to the class.

The third assertion shows the same idea for writes: Reflect.set with a receiver leaves the source at 400 and creates fare on the receiver. That is exactly how assigning to an inherited data property behaves — the write lands on the instance, not the prototype — and Reflect.set is the only way to say it out loud.

The other half of Reflect is tone. Object.defineProperty and delete announce refusal by throwing, which forces a try around a question you only wanted answered; Reflect.defineProperty and Reflect.deleteProperty return false. Neither is more correct, but returning a boolean composes, and it is the form a trap must return anyway.

65 Symbol.toPrimitive decides what your object becomes

hard

Give a money class a Symbol.toPrimitive that answers all three hints: 'number', 'string' and 'default'. Work out which hint each of Number(), a template literal, +, > and == asks for. Add toString and valueOf as well, and see which of the three wins.

Stuck? Three hints, smallest first
  1. 1. Three hints only. Arithmetic and comparison ask for number, String() and template literals ask for string, and binary + and == ask for default.
  2. 2. Symbol.toPrimitive is consulted before valueOf and toString, so those two become unreachable.
  3. 3. JSON.stringify does not convert to a primitive at all. It looks for toJSON.

The answer

class Naira {
  constructor(kobo) { this.kobo = kobo; }
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.kobo / 100;
    if (hint === 'string') return '\u20a6' + (this.kobo / 100).toFixed(2);
    return 'Naira(' + this.kobo + ' kobo)';            // 'default'
  }
  toString() { return 'toString ran'; }
  valueOf() { return -1; }
  toJSON() { return { naira: this.kobo / 100 }; }
}

const fare = new Naira(45000);       // 450 naira
const tip = new Naira(5000);         // 50 naira

const asNumber = [Number(fare), +fare];
const arithmetic = [fare * 2, fare - tip, Math.max(fare, tip)];
const asString = [String(fare), `fare is ${fare}`, fare.toFixed ? 'has toFixed' : 'no toFixed'];
const concatenated = fare + '';
const added = fare + tip;
const compared = [fare > tip, [fare, tip].sort((a, b) => a - b).map((m) => m.kobo)];
const looseEqual = fare == 'Naira(45000 kobo)';
const serialised = JSON.stringify({ fare });
const unreachable = [String(fare) === 'toString ran', Number(fare) === -1];

console.log('number hint :', asNumber, arithmetic);
console.log('string hint :', asString.slice(0, 2));
console.log('default hint:', concatenated, '|', added);
console.log('serialised  :', serialised);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('Number() and unary + ask for the number hint', asNumber, [450, 450]);
test('so does every arithmetic operator and Math function', arithmetic, [900, 400, 450]);
test('String() and a template literal ask for the string hint', asString.slice(0, 2), ['\u20a6450.00', 'fare is \u20a6450.00']);
test('binary + and == ask for default, which is neither of those', [concatenated, added, looseEqual], ['Naira(45000 kobo)', 'Naira(45000 kobo)Naira(5000 kobo)', true]);
test('comparison uses the number hint, so sorting works with no accessor', compared, [true, [5000, 45000]]);
test('toString and valueOf are now unreachable: toPrimitive is asked first', unreachable, [false, false]);
test('JSON.stringify ignores all of it and uses toJSON', serialised, '{"fare":{"naira":450}}');
test('and the object is still an object, with its own data intact', [fare.kobo, typeof fare], [45000, 'object']);

+ between two objects is the one that catches people. It does not ask for a string and it does not ask for a number — it asks for 'default', because at that point the language does not yet know whether it is doing addition or concatenation. Return a number from the default branch and fare + tip is 500; return a string and it concatenates. The fourth assertion is the same hint reached three ways: obj + '', obj + obj and obj == 'text'.

Comparison is different: <, >, -, * and Math.max all ask for 'number', which is what makes the fifth assertion work — a subtraction comparator sorts these objects without touching .kobo. And a template literal always asks for 'string', which is why `${obj}` and obj + '' can produce different text from the same object.

The sixth assertion is the trap. Adding Symbol.toPrimitive to a class that already had toString and valueOf silently retires both of them: OrdinaryToPrimitive is only reached when Symbol.toPrimitive is absent. That is a fine way to break code elsewhere that relied on the old toString, so if you add one, make the string branch return what toString used to.

JSON.stringify is not part of this protocol at all. It never coerces to a primitive; it looks for toJSON and otherwise walks the own enumerable properties. So an object can print as ₦450.00, add as text and serialise as {"naira":450} — three representations, three separate hooks, and no way to unify them.

66 Symbol.toStringTag, and a typeOf that tells the truth

fair

Object.prototype.toString.call(x) is the oldest type check in the language. Find out what it says about a class instance, change its answer with Symbol.toStringTag, and build a typeOf helper on it that names null, arrays, dates, maps and promises. Then make the tag lie and see what still refuses to believe it.

Stuck? Three hints, smallest first
  1. 1. Slice the result: [object X] is always eight characters, then the tag, then a bracket.
  2. 2. The tag is normally written as a getter, so nothing enumerable appears on the object.
  3. 3. A tag of 'Array' changes what toString reports and nothing else.

The answer

const typeOf = (value) => Object.prototype.toString.call(value).slice(8, -1);

class Receipt {
  constructor(total) { this.total = total; }
}
class Tagged {
  constructor(total) { this.total = total; }
  get [Symbol.toStringTag]() { return 'Receipt'; }
}

const plainInstance = typeOf(new Receipt(45000));
const taggedInstance = typeOf(new Tagged(45000));
const stringified = String(new Tagged(45000));
const constructorName = new Receipt(45000).constructor.name;

const primitivesAndBuiltins = [
  typeOf(null), typeOf(undefined), typeOf(42), typeOf('Lagos'),
  typeOf([]), typeOf(new Map()), typeOf(Promise.resolve()), typeOf(/x/),
];
const generator = (function* () {})();
const fromSameMechanism = [typeOf(generator), typeOf(Math), typeOf(JSON)];

const tagged = new Tagged(45000);
const invisible = [Object.keys(tagged), JSON.stringify(tagged), Object.getOwnPropertySymbols(tagged).length];

const liar = { [Symbol.toStringTag]: 'Array', length: 0 };
const lieBelieved = typeOf(liar);
const lieRejected = [Array.isArray(liar), liar instanceof Array];

console.log('untagged, tagged:', plainInstance, taggedInstance);
console.log('built-ins       :', primitivesAndBuiltins);
console.log('the liar        :', lieBelieved, lieRejected);

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('a class instance is just [object Object]: the class name is invisible here', plainInstance, 'Object');
test('Symbol.toStringTag is what changes that answer', taggedInstance, 'Receipt');
test('and String() changes with it, since it falls through to the same method', stringified, '[object Receipt]');
test('constructor.name knew the class all along, and is the wrong tool for null', [constructorName, typeOf(null)], ['Receipt', 'Null']);
test('the built-in tags cover the cases typeof cannot', primitivesAndBuiltins, ['Null', 'Undefined', 'Number', 'String', 'Array', 'Map', 'Promise', 'RegExp']);
test('generators, Math and JSON carry tags from the same mechanism', fromSameMechanism, ['Generator', 'Math', 'JSON']);
test('a getter tag adds no enumerable key, so keys and JSON are unaffected', invisible, [['total'], '{"total":45000}', 0]);
test('the tag can lie, and toString repeats the lie', lieBelieved, 'Array');
test('but Array.isArray and instanceof are not fooled by a string', lieRejected, [false, false]);

Before Symbol.toStringTag existed, Object.prototype.toString.call was reading an internal class name that nothing could change, which is exactly why it was trusted: [object Array] could not be faked. The symbol turned that internal detail into an ordinary lookup, so it is now a convention rather than a guarantee — the eighth and ninth assertions are the before and after in one object.

It is still the best cheap type check going, because typeof collapses null, arrays, dates and maps into 'object', and constructor.name throws on null and undefined and lies whenever a prototype is reassigned. A four-line typeOf that names all of them is worth having.

Note the getter. Written as get [Symbol.toStringTag]() the tag lives on the prototype and no instance carries anything, so Object.keys and JSON.stringify are untouched — the seventh assertion. Written as a class field, [Symbol.toStringTag] = 'Receipt', it becomes an own property on every instance: still invisible to Object.keys because symbol keys always are, but now present in getOwnPropertySymbols and duplicated per instance.

The real reason to set a tag is diagnostics. A wrapper class that reports [object Object] is indistinguishable from a plain object in a log, and giving it a tag costs one line.

67 Symbol.hasInstance rewrites instanceof

hard

x instanceof C asks C whether it recognises x. Give a plain object a Symbol.hasInstance method and use it on the right of instanceof. Then duck-type a class with a static one, make it disagree with the prototype chain in both directions, and confirm what instanceof does with a right-hand side that has neither.

Stuck? Three hints, smallest first
  1. 1. The method receives the left operand and returns something truthy or falsy. It is coerced to a boolean.
  2. 2. On a class, write it as static [Symbol.hasInstance](value).
  3. 3. Without the symbol, the right-hand side must be callable, or instanceof throws.

The answer

const attemptName = (fn) => { try { fn(); return 'no error'; } catch (err) { return err.name; } };

// Not a function, not a constructor, still usable on the right of instanceof.
const Callable = { [Symbol.hasInstance]: (v) => typeof v === 'function' };
const notCallable = {};

const functionMatches = [(() => {}) instanceof Callable, 42 instanceof Callable];
const withoutTheSymbol = attemptName(() => 42 instanceof notCallable);
const rightSideType = typeof Callable;

class Payable {
  static [Symbol.hasInstance](value) {
    return Boolean(value) && typeof value.amount === 'number' && typeof value.pay === 'function';
  }
  constructor(amount) { this.amount = amount; }
  pay() { return 'paid ' + this.amount; }
}

const duck = { amount: 45000, pay: () => 'transfer sent' };
const duckPasses = duck instanceof Payable;
const notInTheChain = [Object.getPrototypeOf(duck) === Payable.prototype, Payable.prototype.isPrototypeOf(duck)];

const real = new Payable(45000);
const realPasses = real instanceof Payable;
const halfShaped = { amount: 45000 } instanceof Payable;

// It can refuse a genuine instance just as easily.
class Fussy {
  static [Symbol.hasInstance]() { return false; }
}
const genuine = new Fussy();
const refused = [genuine instanceof Fussy, Object.getPrototypeOf(genuine) === Fussy.prototype];

// What normally runs is Function.prototype[Symbol.hasInstance], and it is callable.
const theDefault = Function.prototype[Symbol.hasInstance];
const defaultAnswers = [theDefault.call(Array, []), theDefault.call(Array, 'Lagos')];
const primitiveArrives = [] instanceof Callable;

console.log('function instanceof Callable:', functionMatches);
console.log('duck typed                  :', duckPasses, '| in the chain:', notInTheChain);
console.log('genuine instance refused    :', refused);

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('a plain object with hasInstance works on the right of instanceof', functionMatches, [true, false]);
test('even though it is not a function and has no prototype property', [rightSideType, 'prototype' in Callable], ['object', false]);
test('without the symbol, a non-callable right-hand side is a TypeError', withoutTheSymbol, 'TypeError');
test('a static hasInstance duck-types: the right shape passes', [duckPasses, halfShaped], [true, false]);
test('and it passes without being anywhere in the prototype chain', notInTheChain, [false, false]);
test('a genuine instance passes too, because it happens to fit the same shape', realPasses, true);
test('but the trap can refuse a genuine instance while the chain still holds', refused, [false, true]);
test('the default behaviour is itself a callable method you can invoke directly', defaultAnswers, [true, false]);
test('and the check receives the left operand whatever it is, primitives included', [primitiveArrives, 'Lagos' instanceof Callable], [false, false]);

instanceof is not a language primitive that walks prototypes. It is a dispatch: look up Symbol.hasInstance on the right operand, call it with the left one, coerce the answer to a boolean. Only when the symbol is absent does the fallback — Function.prototype[Symbol.hasInstance], which does walk the chain — run. The eighth assertion calls that fallback by hand, which is the clearest way to see it is just a method.

The seventh assertion is the warning. genuine instanceof Fussy is false while Object.getPrototypeOf(genuine) === Fussy.prototype is true. instanceof and the prototype chain are now two different questions, and any code that assumed they were the same — a type guard, a serialiser, a dependency-injection container — is wrong about this class and has no way of knowing.

Where it earns its place: a branded check that survives module duplication. Two copies of a library in one bundle give two distinct classes, so a real instance from copy A fails instanceof against copy B; a hasInstance that tests for a symbol-keyed brand instead passes. Also for abstract shapes — "anything payable" — where there is no single base class to inherit from.

Where it does not: as a validator. The fourth assertion shows the shape check accepting a plain object literal, which is the intent here, but shape checks drift from the class they claim to describe. Add a required field to Payable and the duck still passes.

68 A symbol key is hidden, not private

fair

Put a symbol-keyed field on an order and check every way of listing properties: Object.keys, for...in, JSON.stringify, getOwnPropertySymbols, Reflect.ownKeys, spread and Object.assign. Then show that two symbols with the same description are different keys, and find the conversion that throws.

Stuck? Three hints, smallest first
  1. 1. The string-key APIs skip symbols entirely. There is a separate call for them.
  2. 2. Spread and Object.assign copy own enumerable properties — and a symbol key can be enumerable.
  3. 3. String(sym) is fine. '' + sym is not.

The answer

const AUDIT = Symbol('audit');
const order = { customer: 'Bisi', total: 45000, [AUDIT]: 'Balogun till 3, 15:40' };

const stringKeys = Object.keys(order);
const enumerated = [];
for (const key in order) enumerated.push(key);
const serialised = JSON.stringify(order);
const revived = JSON.parse(serialised);

const symbolKeys = Object.getOwnPropertySymbols(order).map(String);
const everyKey = Reflect.ownKeys(order).map(String);
const readsFine = order[AUDIT];

const spread = { ...order };
const assigned = Object.assign({}, order);
const copiedTheSymbol = [spread[AUDIT], assigned[AUDIT], Object.keys(spread)];
const survivesRoundTrip = revived[AUDIT] === undefined;

const LOOKALIKE = Symbol('audit');
order[LOOKALIKE] = 'Ikeja till 1, 09:05';
const bothCoexist = [order[AUDIT], order[LOOKALIKE], Object.getOwnPropertySymbols(order).length];
const freshMisses = order[Symbol('audit')] === undefined;

const description = [AUDIT.description, String(AUDIT), AUDIT === LOOKALIKE];
let conversion;
try { conversion = '' + AUDIT; } catch (err) { conversion = err.name; }
let templated;
try { templated = `${AUDIT}`; } catch (err) { templated = err.name; }

console.log('string keys :', stringKeys, '| serialised:', serialised);
console.log('symbol keys :', symbolKeys, '| every key:', everyKey);
console.log('conversions :', conversion, templated, String(AUDIT));

Output


      
9 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 9 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

9 checks

test('Object.keys and for...in skip the symbol completely', [stringKeys, enumerated], [['customer', 'total'], ['customer', 'total']]);
test('JSON.stringify drops it, so it does not survive a round trip', [serialised, survivesRoundTrip], ['{"customer":"Bisi","total":45000}', true]);
test('getOwnPropertySymbols finds it and Reflect.ownKeys lists both kinds', [symbolKeys, everyKey], [['Symbol(audit)'], ['customer', 'total', 'Symbol(audit)']]);
test('and reading it by the symbol is ordinary property access', readsFine, 'Balogun till 3, 15:40');
test('spread and Object.assign do copy it: hidden is not private', copiedTheSymbol, ['Balogun till 3, 15:40', 'Balogun till 3, 15:40', ['customer', 'total']]);
test('two symbols with the same description are different keys, so both values live', bothCoexist, ['Balogun till 3, 15:40', 'Ikeja till 1, 09:05', 2]);
test('a fresh symbol with that description reads nothing: the value is the identity', freshMisses, true);
test('the description is readable, and String() converts safely', description, ['audit', 'Symbol(audit)', false]);
test('but implicit conversion throws, in both + and a template literal', [conversion, templated], ['TypeError', 'TypeError']);

Symbol keys are for metadata you do not want in the way: an audit trail, a cache slot, a framework's bookkeeping. They are skipped by Object.keys, for...in, JSON.stringify and getOwnPropertyNames, so none of the loops in existing code trips over them and no serialiser leaks them.

They are not private, and the fifth assertion is the one to remember. Spread and Object.assign copy own enumerable properties, and enumerable has nothing to do with being a string, so the symbol comes along — while Object.keys of the copy still shows two keys. Anyone can also list them with getOwnPropertySymbols. For real privacy you need a #field or a WeakMap, as exercise 63 shows.

The identity point is the sixth and seventh assertions together. A symbol's description is a label for debugging, nothing more: two symbols described as 'audit' are two distinct keys and both values sit on the object at once. That is the feature — it is why a library can add Symbol('cache') to your object with no chance of colliding with yours — and it is why you must keep the symbol itself around, because there is no way to look one up by description. Exercise 69 is the deliberate exception.

The last assertion is the sharp edge. A symbol refuses implicit string conversion and throws a TypeError, so 'key: ' + sym and `${sym}` both fail. It is deliberate — silently turning a unique key into the text "Symbol(audit)" would defeat the point — but it means every log line and every error message that might touch a key needs String(k), which is why the traps in exercise 60 are written that way.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.