Emmanuel Akinfulubi

JavaScript: Fundamental (ES6), part 2, 1–37

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

Exercises 1 to 37 of 118

1 The iterator result protocol, in full

fair

Write a generator that yields three values and then returns a fourth. Drive it with next() by hand and account for every result object, including the ones after it finishes. Then find out where the returned value went when you spread it.

Stuck? Three hints, smallest first
  1. 1. Every next() gives back an object with exactly two properties, value and done.
  2. 2. A return in the generator body arrives as the result whose done is true.
  3. 3. for...of and spread stop at done: true, so they never see that value.

The answer

function* countdown(from) {
  while (from > 0) yield from--;
  return 'lift off';
}

const it = countdown(3);
const steps = [it.next(), it.next(), it.next(), it.next(), it.next()];

console.log('step by step:');
for (const s of steps) console.log(' ', JSON.stringify(s));
console.log('spread     :', [...countdown(3)], '<- no "lift off"');
console.log('own iterator?', it[Symbol.iterator]() === it);

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 next yields the first value', steps[0], { value: 3, done: false });
test('done stays false while values remain', steps.slice(0, 3).map((s) => s.done), [false, false, false]);
test('the returned value arrives with done true', steps[3], { value: 'lift off', done: true });
test('every next after that is done with no value', [steps[4].done, steps[4].value === undefined], [true, true]);
test('spread collects only the yielded values', [...countdown(3)], [3, 2, 1]);
test('so the returned value is invisible to it', [...countdown(3)].includes('lift off'), false);
test('a generator object is its own iterator', it[Symbol.iterator]() === it, true);
test('and it is one-shot: a spent one yields nothing', [...it], []);

The result object is the whole protocol. Two properties, value and done, and every consumer in the language — for...of, spread, destructuring, yield*, Promise.all over an iterable — is written against those two and nothing else.

The trap is the return value. It is delivered exactly once, alongside done: true, and every ordinary consumer stops when it sees done without reading value. So a return in a generator is only useful to code driving next() itself, or to a yield* that is expecting it. If you want a value in the output, yield it.

Being its own iterator is what makes a generator single-use. An array hands out a fresh iterator each time you loop it; a generator hands back this, and this remembers it is finished.

Answers JS Fundamental II 1 in the original set.

2 Send values back in with next(value)

hard

Build a running-total generator where each next(amount) feeds an amount in and gets the new total out. Then explain what happened to the argument of the first next().

Stuck? Three hints, smallest first
  1. 1. const x = yield y sends y out and, when resumed, evaluates to whatever was passed to next().
  2. 2. The first next() only starts the body running. There is no suspended yield waiting to receive its argument.
  3. 3. That is why libraries "prime" a generator with one bare next() before using it.

The answer

function* runningTotal() {
  let total = 0;
  while (true) {
    const amount = yield total;   // out goes the total, in comes the next amount
    total += amount;
  }
}

const till = runningTotal();
const first = till.next(500);     // 500 is DISCARDED: no yield is waiting for it
const second = till.next(1000);
const third = till.next(250);

// The fix is to prime it, then hide the priming behind a small API.
function openTill() {
  const g = runningTotal();
  g.next();                       // run to the first yield and drop that value
  return { add: (naira) => g.next(naira).value };
}
const shop = openTill();
const takings = [shop.add(4500), shop.add(300), shop.add(1200)];

const sentNothing = (() => { const g = runningTotal(); g.next(); return g.next().value; })();

console.log('first next(500) gave', first.value, '<- the 500 went nowhere');
console.log('then next(1000) ->', second.value, ' next(250) ->', third.value);
console.log('primed till     :', takings);
console.log('next() with no argument:', sentNothing);

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('the first next only starts the body', first.value, 0);
test('so its argument never arrives', first.value === 500, false);
test('the second next really does send a value in', second.value, 1000);
test('and the amounts accumulate', third.value, 1250);
test('priming it first makes the API read naturally', takings, [4500, 4800, 6000]);
test('next() with no argument sends undefined, so the sum goes NaN', Number.isNaN(sentNothing), true);
test('a generator with no return is never done', till.next(1).done, false);

yield is an expression, not a statement, and that is the whole exercise. It has a value on resumption, and that value is the argument of the next() that resumed it. Two-way communication with no callbacks.

The discarded first argument catches everyone. When you call g.next(500) on a fresh generator, the body has not started, so there is no suspended yield expression for 500 to become. It is silently dropped — no error, no warning. Priming with a bare next() is the standard fix, and wrapping the generator so callers cannot forget is better than documenting it.

The NaN assertion is the failure mode in production: one next() with a missing argument and every total after it is NaN, because NaN is contagious through arithmetic and never throws.

Answers JS Fundamental II 2 in the original set.

3 return() on a generator runs its finally

hard

Write a generator that acquires something in a try and releases it in a finally. Prove that return() releases it, that breaking out of a for...of releases it too, and that simply walking away from a half-read generator does not.

Stuck? Three hints, smallest first
  1. 1. gen.return(v) resumes the generator as though a return v had appeared at the paused yield.
  2. 2. for...of calls return() on the iterator when the loop exits early, which includes break, throw and an early return.
  3. 3. Nothing calls it for you if you just stop touching the generator. There is no finaliser.

The answer

function* connection(log) {
  log.push('open');
  try {
    yield 'row 1';
    yield 'row 2';
    yield 'row 3';
  } finally {
    log.push('close');            // runs on normal end, on return(), on throw()
  }
}

const log = [];
const rows = connection(log);
const read = [rows.next().value, rows.next().value];
const ended = rows.return('stopped early');
const afterwards = rows.next();

// for...of does the same thing for you when you break.
const loopLog = [];
const loopRead = [];
for (const row of connection(loopLog)) {
  loopRead.push(row);
  if (loopRead.length === 1) break;
}

// And nothing at all happens if you simply stop reading.
const abandonedLog = [];
const abandoned = connection(abandonedLog);
abandoned.next();

console.log('read      :', read);
console.log('return()  :', JSON.stringify(ended));
console.log('log       :', log);
console.log('after break:', loopLog, loopRead);
console.log('abandoned :', abandonedLog, '<- never closed');

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('two rows were read', read, ['row 1', 'row 2']);
test('return() reports done, with the value you gave it', ended, { value: 'stopped early', done: true });
test('and the finally block ran', log, ['open', 'close']);
test('nothing more comes out afterwards', [afterwards.done, afterwards.value === undefined], [true, true]);
test('breaking out of for...of calls return() for you', loopLog, ['open', 'close']);
test('so cleanup happens on an early exit too', loopRead, ['row 1']);
test('but a generator you simply abandon never cleans up', abandonedLog, ['open']);
test('the finally runs at most once', connection(log) && log.filter((l) => l === 'close').length, 1);

This is the mechanism that makes a generator safe to hold a resource. return() is not "stop it", it is "resume it as if a return statement were sitting at the paused yield" — so the finally runs, exactly as it would on a real early return.

for...of calling return() on early exit is specified behaviour (the IteratorClose step), and it is why break out of a loop over a database cursor closes the cursor. It also fires for throw and for a return out of the enclosing function.

The last two assertions are the boundary. There is no destructor in JavaScript. A generator that is paused inside a try and then dropped on the floor has its finally skipped for ever, and the file handle or connection leaks. If a generator owns something, the consumer has to finish it, break out of a loop over it, or call return() — and try { ... } finally { gen.return(); } around the consumer is how you guarantee that.

Answers JS Fundamental II 3 in the original set.

4 throw() into a running generator

hard

Throw an error into a paused generator so that its own try/catch handles it and it carries on. Then show what happens when the generator has no handler, and when you throw before it has started.

Stuck? Three hints, smallest first
  1. 1. gen.throw(err) raises err at the paused yield, as if that line had thrown.
  2. 2. If the generator catches it, throw() returns the next result object and the generator is still alive.
  3. 3. If it does not, the error comes back out of your throw() call and the generator is finished.

The answer

function* resilient(log) {
  let n = 0;
  while (n < 4) {
    try {
      yield n++;
    } catch (err) {
      log.push('caught ' + err.message);   // recover and keep going
    }
  }
  return 'finished at ' + n;
}

const log = [];
const g = resilient(log);
const started = g.next();
const resumed = g.throw(new Error('bad row'));
const rest = [g.next().value, g.next().value, g.next().value];

// A generator with no try/catch cannot absorb it.
function* fragile() { yield 1; yield 2; }
const f = fragile();
f.next();
const escaped = (() => {
  try { f.throw(new Error('boom')); return 'no throw'; } catch (e) { return e.message; }
})();

// Neither can one that has not started: there is no active try block yet.
const tooSoon = (() => {
  try { resilient([]).throw(new Error('too soon')); return 'no throw'; } catch (e) { return e.message; }
})();

console.log('started :', JSON.stringify(started));
console.log('resumed :', JSON.stringify(resumed), '<- alive, and one value further on');
console.log('log     :', log);
console.log('rest    :', rest);
console.log('no handler inside:', escaped, ' before starting:', 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('it yielded before anything went wrong', started, { value: 0, done: false });
test('throw() raises the error at the paused yield, inside the generator', log, ['caught bad row']);
test('a caught throw leaves the generator alive and moves it on', resumed, { value: 1, done: false });
test('so it still finishes normally', rest, [2, 3, 'finished at 4']);
test('a generator with no handler lets the error back out', escaped, 'boom');
test('and that one is finished', f.next().done, true);
test('throwing before the first next() cannot be caught inside either', tooSoon, 'too soon');

throw() is the mirror of next(): instead of resuming the yield with a value, it resumes it with an exception. That is what lets a consumer report a problem back to the producer — the generator is the one that knows whether the failure is recoverable, so it is the one that should decide.

The two failure cases matter as much as the success. With no try around the yield, the error passes straight through the generator and out of your throw() call, and the generator is left completed. Before the first next() there is no suspended yield at all, so there is nothing to throw at and the error is simply rethrown at you — which is the same shape of surprise as next(value) discarding its argument in exercise 2.

This is the plumbing that async/await was built on. Before it was syntax, await was a library driving a generator: next(resolvedValue) on success, throw(rejection) on failure. Which is precisely why try/catch works around an await.

Answers JS Fundamental II 4 in the original set.

5 Delegate with yield*, and catch what it returns

fair

Use yield* to splice one generator's output into another. Capture the delegated generator's return value, delegate to a plain array and a string as well, and show that a value sent with next() reaches all the way through.

Stuck? Three hints, smallest first
  1. 1. yield* inner() yields everything inner yields, one at a time, inline.
  2. 2. The expression yield* inner() evaluates to inner’s return value.
  3. 3. yield* takes any iterable, and it forwards next(), throw() and return() to the delegate.

The answer

function* inner() {
  yield 'a';
  yield 'b';
  return 'inner result';
}

function* outer() {
  const handedBack = yield* inner();   // the inner RETURN lands here
  yield handedBack;
  yield* [1, 2];                       // any iterable
  yield* 'hi';                         // a string is one too
}

// The hand-rolled relay yields the same values and loses the return value.
function* relay() {
  for (const v of inner()) yield v;
}

function* deep(n) {
  if (n === 0) return;
  yield n;
  yield* deep(n - 1);
}

// next() is forwarded through the delegation, not swallowed by it.
function* echo() { const got = yield 'ready'; return 'got ' + got; }
function* wrapper() { const r = yield* echo(); yield r; }
const w = wrapper();
w.next();
const passedThrough = w.next('cash').value;

console.log('outer :', [...outer()]);
console.log('relay :', [...relay()], '<- the return value is gone');
console.log('deep  :', [...deep(3)]);
console.log('sent through the delegation:', passedThrough);

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('delegation yields the inner values inline', [...outer()].slice(0, 2), ['a', 'b']);
test('the yield* expression is the inner return value', [...outer()][2], 'inner result');
test('and any iterable can be delegated to', [...outer()], ['a', 'b', 'inner result', 1, 2, 'h', 'i']);
test('a for...of relay yields the same values', [...relay()], ['a', 'b']);
test('but throws the return value away', [...relay()].length, 2);
test('yield* recurses happily', [...deep(3)], [3, 2, 1]);
test('a value sent with next() reaches the delegated generator', passedThrough, 'got cash');

yield* is not sugar for a for...of relay, and the difference is the return value. The relay reads the delegate through the iterator protocol, which stops at done: true and never looks at the value beside it. yield* is specified to pick that value up and become it.

The other difference is transparency. yield* forwards next(v), throw(e) and return(v) down to the delegate, so a nested stack of generators behaves like one generator from the outside. A hand-written relay forwards none of that: next('cash') would resume the relay's own yield and the inner generator would never see it.

Recursion plus yield* is the idiomatic tree walk, and exercise 10 does it properly. Be aware that it costs a frame per level per value, so a very deep structure is better flattened with an explicit stack.

Answers JS Fundamental II 5 in the original set.

6 A generator as a state machine

hard

Model an order's lifecycle as a generator. Each next(event) sends an event in and yields the new state; an event the current state cannot handle is ignored; reaching a terminal state ends the generator and returns the full history.

Stuck? Three hints, smallest first
  1. 1. Keep the transitions as data: state -> event -> next state. The generator only walks the table.
  2. 2. continue after an unrecognised event, so the same state is yielded again.
  3. 3. A state with no outgoing transitions is terminal, which is the loop condition.

The answer

const TRANSITIONS = {
  pending: { pay: 'paid', cancel: 'cancelled' },
  paid: { ship: 'shipped', refund: 'refunded' },
  shipped: { deliver: 'delivered' },
  cancelled: {},
  refunded: {},
  delivered: {},
};

function* order(start = 'pending') {
  let state = start;
  const history = [state];
  while (Object.keys(TRANSITIONS[state]).length > 0) {
    const event = yield state;
    const next = TRANSITIONS[state][event];
    if (next === undefined) continue;      // this state cannot handle that event
    state = next;
    history.push(state);
  }
  return history;
}

const o = order();
const a = o.next();               // start it
const b = o.next('pay');
const ignored = o.next('deliver');  // shipping has not happened yet
const c = o.next('ship');
const done = o.next('deliver');

const cancelled = (() => { const x = order(); x.next(); return x.next('cancel'); })();
const independent = (() => {
  const p = order(); const q = order();
  p.next(); q.next(); p.next('pay');
  return q.next('cancel').value;
})();

console.log('pending ->', b.value, '->', c.value);
console.log('an event it cannot handle:', ignored.value, '<- unchanged');
console.log('terminal:', done.done, JSON.stringify(done.value));
console.log('cancelled route:', cancelled.value);

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('it starts pending', a.value, 'pending');
test('paying moves it on', b.value, 'paid');
test('an event this state cannot handle is ignored', ignored.value, 'paid');
test('shipping then delivering runs it out', c.value, 'shipped');
test('a terminal state ends the generator', done.done, true);
test('and the return value is the whole history', done.value, ['pending', 'paid', 'shipped', 'delivered']);
test('cancelling from pending is a different terminal state', cancelled, { value: ['pending', 'cancelled'], done: true });
test('each machine holds its own state, so two orders never interfere', independent, ['pending', 'cancelled']);

The state lives in the generator's suspended frame. There is no this.state to be mutated from elsewhere, no reducer to be called with the wrong arguments, and no way to reach the machine except by sending it an event. Two orders in flight are two frames, and the last assertion is the proof that they do not share anything.

Keeping the transitions as a plain table rather than a chain of ifs is what makes "ignore an impossible event" one line instead of a case per state. It also means the terminal check is Object.keys(...).length === 0 rather than a hand-maintained list, so adding refunded needed no code change at all.

The honest limitation: a generator state machine cannot be inspected without advancing it. If you need to ask "what state is it in?" from outside, or to persist and resume it, keep the state in a plain object and use a reducer. The generator wins where the machine is driven by a single consumer, which is exactly the parsing case in exercises 111 to 116.

Answers JS Fundamental II 6 in the original set.

7 An infinite sequence, and taking from it

hard

Write an infinite generator of natural numbers and a take(iterable, n) that pulls only the first n. Count how many values the source actually produced. Then take twice from the same generator and explain the second result.

Stuck? Three hints, smallest first
  1. 1. An infinite generator is just while (true) yield .... Nothing runs until something pulls.
  2. 2. take should return as soon as it has enough, which is what stops the source.
  3. 3. Ending a for...of early closes the iterator it was reading. That is fine for a fresh generator and fatal for a shared one.

The answer

function* naturals(from = 1) {
  let n = from;
  while (true) yield n++;
}

function* take(iterable, count) {
  if (count <= 0) return;
  let taken = 0;
  for (const value of iterable) {
    yield value;
    if (++taken === count) return;
  }
}

// A version that drives next() itself, so it never closes the source.
function* takeFrom(iterator, count) {
  for (let i = 0; i < count; i++) {
    const step = iterator.next();
    if (step.done) return;
    yield step.value;
  }
}

let produced = 0;
function* counted() { let n = 1; while (true) { produced++; yield n++; } }

const firstThree = [...take(counted(), 3)];
const producedForThree = produced;
const nothing = [...take(counted(), 0)];
const producedAfterZero = produced;

const shared = naturals();
const twiceOverForOf = [[...take(shared, 2)], [...take(shared, 2)]];
const openSource = naturals();
const twiceOverNext = [[...takeFrom(openSource, 2)], [...takeFrom(openSource, 2)]];

console.log('first three   :', firstThree, 'source produced', producedForThree);
console.log('take(..., 0)  :', nothing, 'source produced', producedAfterZero);
console.log('shared via for...of:', twiceOverForOf, '<- second pass is empty');
console.log('shared via next()  :', twiceOverNext);

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('take pulls only what it needs', firstThree, [1, 2, 3]);
test('and the source produced exactly three values', producedForThree, 3);
test('taking zero pulls nothing at all', [nothing, producedAfterZero], [[], 3]);
test('taking more than exists stops at the end', [...take([1, 2], 5)], [1, 2]);
test('an infinite source is fine as long as you stop', [...take(naturals(10), 3)], [10, 11, 12]);
test('for...of CLOSES the generator on early exit, so a shared source is spent', twiceOverForOf, [[1, 2], []]);
test('driving next() by hand leaves it open', twiceOverNext, [[1, 2], [3, 4]]);

The produced === 3 assertion is the point of lazy sequences. The source is written as though it goes on for ever, and the cost is set by the consumer, not the producer. Rewrite counted to build an array first and there is no n at which it terminates.

The sixth assertion is the trap, and it costs people an afternoon. for...of is specified to call return() on its iterator when it exits early — the same mechanism as exercise 3's cleanup — and on a generator that means "you are finished". So a take written with for...of quietly destroys any source it is handed twice. [[1, 2], []] is the assertion of that.

takeFrom is the fix: drive next() yourself and never enter a for...of, so nothing calls return(). That is why library helpers take an iterator when they intend to leave it resumable and an iterable when they intend to consume it whole. Naming the parameter honestly is most of the documentation.

Answers JS Fundamental II 7 in the original set.

8 map and filter as generators, composed

hard

Write mapG and filterG as generators and chain them with a takeG over an infinite source. Count how many source values the whole pipeline touched, and prove that building the chain does no work at all.

Stuck? Three hints, smallest first
  1. 1. Each stage takes an iterable and yields; the stage above pulls one value at a time through the whole chain.
  2. 2. Nothing runs when you build the chain. The generator objects exist, suspended, until something iterates.
  3. 3. Count the pulls in the source with a counter, and check it against what the pipeline needed.

The answer

function* mapG(iterable, fn) { for (const v of iterable) yield fn(v); }
function* filterG(iterable, keep) { for (const v of iterable) if (keep(v)) yield v; }
function* takeG(iterable, n) {
  if (n <= 0) return;
  let i = 0;
  for (const v of iterable) { yield v; if (++i === n) return; }
}

let touched = 0;
function* meterReadings() {
  let naira = 500;
  while (true) { touched++; yield naira; naira += 500; }
}

const roundThousands = takeG(
  mapG(filterG(meterReadings(), (n) => n % 1000 === 0), (n) => `₦${n}`),
  3
);
const formatted = [...roundThousands];

let pulls = 0;
function* three() { for (const v of [1, 2, 3]) { pulls++; yield v; } }
const chain = mapG(three(), (n) => n * 2);
const pullsBeforeIterating = pulls;
const doubled = [...chain];

console.log('formatted :', formatted);
console.log('source touched', touched, 'times for three results');
console.log('pulls before iterating:', pullsBeforeIterating, ' after:', pulls);
console.log('second pass over the same chain:', [...chain]);

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('the pipeline produced three formatted amounts', formatted, ['₦1000', '₦2000', '₦3000']);
test('the source was pulled exactly six times, not infinitely', touched, 6);
test('building a chain pulls nothing', pullsBeforeIterating, 0);
test('iterating it does the work, once per value', [doubled, pulls], [[2, 4, 6], 3]);
test('mapG over an array behaves the same way', [...mapG([1, 2, 3], (n) => n + 1)], [2, 3, 4]);
test('filterG can legitimately produce nothing', [...filterG([1, 3, 5], (n) => n % 2 === 0)], []);
test('an exhausted chain is empty the second time', [...chain], []);

touched === 6 is the difference between a generator pipeline and array.filter(...).map(...).slice(0, 3). The array version evaluates each stage completely before the next begins, so it allocates two intermediate arrays and, over an infinite source, never returns. The generator version interleaves: one value walks the whole chain, then the next.

Six, not three, because filterG discarded the odd half-thousands on the way. Laziness bounds the work by what the consumer asked for, it does not make the filter free.

The cost is a stack of next() calls per value, which is slower per element than an array method on small inputs. Reach for this when the input is large, infinite, or expensive to produce — a paged API, a file, a socket — and not to shave allocations off a list of ten. The last assertion is the other cost: a chain is a generator, so it is single-use, and a chain you want to reuse has to be rebuilt from a function.

Answers JS Fundamental II 8 in the original set.

9 A generator-based id factory

fair

Write an id factory as a generator: it yields INV-0001, INV-0002 and so on for ever, and next(n) reseats the counter at n. Two factories must never collide, and the counter must not be reachable from outside.

Stuck? Three hints, smallest first
  1. 1. String(n).padStart(4, ‘0’) gives the zero padding.
  2. 2. Read the incoming value from the yield expression and decide between it and n + 1.
  3. 3. The counter is a local variable in a suspended frame, so there is no property on the generator object to find.

The answer

function* idFactory(prefix, start = 1) {
  let n = start;
  while (true) {
    const reseat = yield `${prefix}-${String(n).padStart(4, '0')}`;
    n = reseat === undefined ? n + 1 : reseat;
  }
}

const invoices = idFactory('INV');
const ids = [invoices.next().value, invoices.next().value, invoices.next().value];
const jumped = invoices.next(100).value;
const carriedOn = invoices.next().value;

const twoStreams = (() => {
  const a = idFactory('LAG');
  const b = idFactory('ABJ');
  return [a.next().value, b.next().value, a.next().value, b.next().value];
})();

const long = (() => { const g = idFactory('X'); g.next(); return g.next(99999).value; })();

console.log('sequential :', ids);
console.log('reseated   :', jumped, '->', carriedOn);
console.log('two streams:', twoStreams);
console.log('own keys on the generator object:', Object.keys(invoices));

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('ids are sequential and zero-padded', ids, ['INV-0001', 'INV-0002', 'INV-0003']);
test('sending a number reseats the counter', jumped, 'INV-0100');
test('and it carries on from there', carriedOn, 'INV-0101');
test('two factories never collide', twoStreams, ['LAG-0001', 'ABJ-0001', 'LAG-0002', 'ABJ-0002']);
test('the counter is private: there is no own property to poke', Object.keys(invoices).length, 0);
test('a factory is never done', invoices.next().done, false);
test('padStart only pads, it never truncates', long, 'X-99999');
test('a custom start is respected', idFactory('R', 42).next().value, 'R-0042');

This is the closure-plus-counter pattern from part 1, with the counter moved into a suspended frame instead of a closed-over variable. Both give privacy; the generator additionally gives you the iterator protocol for free, so take(idFactory(‘INV’), 5) works with no extra code and the factory can be handed to anything that consumes an iterable.

Object.keys(invoices).length === 0 is worth asserting because it is a genuine guarantee, not a convention. There is no underscore-prefixed field to reach in and set, and no way to observe the counter except by advancing it.

The reseat path is where care is needed. Testing reseat === undefined rather than truthiness is deliberate: next(0) should legitimately reseat to zero, and reseat || n + 1 would silently ignore it. That is the same class of bug as value || fallback eating a valid 0 or ‘’.

Answers JS Fundamental II 9 in the original set.

10 Flatten a tree with recursion and yield*

hard

Walk a nested organisation tree depth-first with a recursive generator, carrying the depth along. Then write a breadth-first walk with an explicit queue and show that the two orders differ. Read only the first two nodes of the tree to prove the walk is lazy.

Stuck? Three hints, smallest first
  1. 1. yield the node, then yield* walk(child, depth + 1) for each child.
  2. 2. Breadth-first cannot be written with recursion the same way: it needs a queue you shift from.
  3. 3. Drive next() twice instead of spreading, and the rest of the tree is never visited.

The answer

const org = {
  name: 'Lagos HQ',
  staff: 12,
  units: [
    { name: 'Ikeja branch', staff: 5, units: [{ name: 'Ikeja counter', staff: 2, units: [] }] },
    { name: 'Yaba branch', staff: 3, units: [] },
  ],
};

function* walk(node, depth = 0) {
  yield { name: node.name, staff: node.staff, depth };
  for (const child of node.units ?? []) yield* walk(child, depth + 1);
}

function* levels(root) {
  const queue = [root];
  while (queue.length) {
    const node = queue.shift();
    yield node.name;
    queue.push(...(node.units ?? []));
  }
}

const flat = [...walk(org)];
let total = 0;
for (const node of walk(org)) total += node.staff;

const lazily = walk(org);
const firstTwo = [lazily.next().value.name, lazily.next().value.name];

let chain = { name: 'n0', staff: 0, units: [] };
for (let i = 1; i <= 200; i++) chain = { name: 'n' + i, staff: 0, units: [chain] };
const deepWalk = [...walk(chain)];

console.log('depth-first  :', flat.map((n) => `${'  '.repeat(n.depth)}${n.name}`).join(' | '));
console.log('breadth-first:', [...levels(org)]);
console.log('staff total  :', total);
console.log('read two, stopped:', firstTwo);

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('depth-first, pre-order', flat.map((n) => n.name), ['Lagos HQ', 'Ikeja branch', 'Ikeja counter', 'Yaba branch']);
test('the depth comes along with each node', flat.map((n) => n.depth), [0, 1, 2, 1]);
test('breadth-first visits siblings before children', [...levels(org)], ['Lagos HQ', 'Ikeja branch', 'Yaba branch', 'Ikeja counter']);
test('the whole tree totals 22 staff', total, 22);
test('a leaf walks to just itself', [...walk({ name: 'Kiosk', staff: 1, units: [] })].length, 1);
test('a missing units array is fine', [...walk({ name: 'Solo', staff: 1 })].map((n) => n.name), ['Solo']);
test('reading lazily costs only what you read', firstTwo, ['Lagos HQ', 'Ikeja branch']);
test('200 levels of nesting still walks, deepest last', [deepWalk.length, deepWalk.at(-1).depth], [201, 200]);

Recursion plus yield* is the shortest correct tree walk in the language, and it reads like the definition of the tree: a node, then its subtrees. Compare it with the accumulator version — const out = []; visit(node, out); — which needs the array threaded through every call and returns nothing usable until the whole tree is walked.

Depth-first is naturally recursive because the call stack is the stack you need. Breadth-first is not: a queue you shift from cannot be expressed as recursion without carrying the queue explicitly, so levels stays a loop. That asymmetry, not the output order, is the reason the two look so different.

The 200-level assertion is a limit worth knowing. Each level of yield* adds a frame, and every value yielded from the bottom is relayed up through all of them, so a walk of a tree of depth d costs O(d) per value. Fine for filesystems and org charts, wrong for a linked list of 100,000 nodes — flatten that with an explicit stack instead.

Answers JS Fundamental II 10 in the original set.

11 An iterable you can loop over twice

fair

Make a class whose instances can be iterated more than once. Then build the broken version that caches a single generator and show that its second pass is empty. Work out which of the two protocols — iterable or iterator — each one implements.

Stuck? Three hints, smallest first
  1. 1. A generator method named [Symbol.iterator] returns a fresh generator on every call.
  2. 2. An iterable hands out an iterator; an iterator has next(). A generator object is both, and that is the confusion.
  3. 3. An array is re-iterable. The iterator you get from an array is not.

The answer

class Ledger {
  constructor(rows) { this.rows = rows; }
  *[Symbol.iterator]() { for (const row of this.rows) yield row; }
}

const broken = {
  rows: [1, 2, 3],
  cached: null,
  [Symbol.iterator]() {
    if (!this.cached) this.cached = (function* (rows) { yield* rows; })(this.rows);
    return this.cached;                 // the SAME iterator every time
  },
};

const ledger = new Ledger([
  { item: 'rice, 50kg', naira: 45000 },
  { item: 'palm oil', naira: 12000 },
]);

const firstPass = [...ledger];
const secondPass = [...ledger];
const brokenPasses = [[...broken], [...broken]];

const arrayTwice = (() => { const a = [1, 2]; return [[...a], [...a]]; })();
const iteratorTwice = (() => { const it = [1, 2][Symbol.iterator](); return [[...it], [...it]]; })();
const [firstRow, secondRow] = new Ledger(['x', 'y']);

console.log('two passes over the ledger:', firstPass.length, secondPass.length);
console.log('two passes over the broken one:', brokenPasses);
console.log('array twice   :', arrayTwice);
console.log('iterator twice:', iteratorTwice);

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 generator method makes the object re-iterable', [firstPass.length, secondPass.length], [2, 2]);
test('and both passes see the same rows', firstPass, secondPass);
test('a cached generator makes it single-use', brokenPasses, [[1, 2, 3], []]);
test('an array is re-iterable, because values() is fresh each time', arrayTwice, [[1, 2], [1, 2]]);
test('but its iterator is not', iteratorTwice, [[1, 2], []]);
test('an array is not its own iterator', [1, 2][Symbol.iterator]() === [1, 2], false);
test('while a generator object is', (() => { const g = (function* () {})(); return g[Symbol.iterator]() === g; })(), true);
test('destructuring uses the same protocol', [firstRow, secondRow], ['x', 'y']);

Two protocols, one object, endless confusion. Iterable means "has [Symbol.iterator](), which returns an iterator". Iterator means "has next()". A generator object satisfies both, and its [Symbol.iterator]() returns this — which is exactly why it can only be looped once.

So the rule for anything you hand out: return a new iterator each time [Symbol.iterator] is called. A generator method does that automatically, because calling a generator function is what creates the generator object. The broken version above caches it and thereby implements "iterable" with iterator semantics, and the failure is silent — the first loop works perfectly and the second one sees an empty collection.

The array pair is the same lesson in built-in form. [...a] twice works; [...a[Symbol.iterator]()] twice does not. If a function accepts an iterable and loops it more than once, it has to document that, or take an array.

Answers JS Fundamental II 11 in the original set.

12 Symbol.asyncIterator, written by hand

hard

Give a plain object a [Symbol.asyncIterator] whose next() returns promises, and drive it with for await...of. Then check what a plain for...of makes of it, and what for await does with an ordinary array of promises.

Stuck? Three hints, smallest first
  1. 1. The async protocol is the same two-property result object, wrapped in a promise.
  2. 2. for...of looks for Symbol.iterator only, so it will not accept an async-only object.
  3. 3. for await falls back to Symbol.iterator when there is no async one, awaiting each value it finds.

The answer

const tills = {
  amounts: [1200, 3400, 800],
  [Symbol.asyncIterator]() {
    const amounts = this.amounts;
    let i = 0;
    return {
      next() {
        return i < amounts.length
          ? Promise.resolve({ value: amounts[i++], done: false })
          : Promise.resolve({ value: undefined, done: true });
      },
    };
  },
};

const collect = async (source) => {
  const out = [];
  for await (const value of source) out.push(value);
  return out;
};

const seen = await collect(tills);
const syncRefused = (() => {
  try { for (const x of tills) return 'looped ' + x; return 'empty'; }
  catch (e) { return e.constructor.name; }
})();
const emptySource = { [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ done: true }) }) };

console.log('for await gave:', seen, 'total', seen.reduce((a, b) => a + b, 0));
console.log('plain for...of :', syncRefused);
console.log('next() returns a promise?', typeof tills[Symbol.asyncIterator]().next().then);

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('for await...of drives the async iterator', seen, [1200, 3400, 800]);
test('each next() hands back a promise', typeof tills[Symbol.asyncIterator]().next().then, 'function');
test('a plain for...of refuses an async-only iterable', syncRefused, 'TypeError');
test('an empty async source loops zero times', await collect(emptySource), []);
test('for await also accepts a plain sync iterable', await collect([1, 2, 3]), [1, 2, 3]);
test('and awaits every value it finds there', await collect([Promise.resolve('a'), Promise.resolve('b')]), ['a', 'b']);
test('so a promise of a promise is unwrapped too', await collect([Promise.resolve(Promise.resolve('deep'))]), ['deep']);
test('the two symbols are different keys', Symbol.iterator === Symbol.asyncIterator, false);

There is nothing new in the async protocol. Same result object, same done, same value — only wrapped in a promise, so the consumer awaits each step instead of reading it. Everything you know about Symbol.iterator transfers, including that early exit calls return().

The fallback in the fifth and sixth assertions is the part that saves you writing adapters. for await looks for Symbol.asyncIterator, and if the object has only Symbol.iterator it uses that and awaits each value on the way out. So an array of promises works directly, which is the ordinary case for "fire off a few requests and handle them in order".

That convenience hides a cost worth naming. for await over an array of promises awaits them one at a time in order, so if the third resolves first you still wait for the first two. They were all started when the array was built, so this is not the serialisation problem of exercise 26 — but if you want results as they arrive rather than in order, you want Promise.all or a settled-as-they-come helper, not this loop.

Answers JS Fundamental II 12 in the original set.

13 An async generator, and composing two of them

fair

Write an async function* that reads rows from a fake store, and a second one that filters the first. Prove the pipeline is lazy by counting reads, and check what an async generator object does and does not have on it.

Stuck? Three hints, smallest first
  1. 1. async function* lets you await inside and yield out. Consume it with for await.
  2. 2. Chain them by passing one async generator into the for await of the next.
  3. 3. It supplies Symbol.asyncIterator and, deliberately, no Symbol.iterator.

The answer

const store = {
  rows: [
    { plate: 'LAG-441-XA', fee: 2500 },
    { plate: 'ABJ-772-KM', fee: 4000 },
    { plate: 'KAN-118-QQ', fee: 1500 },
  ],
  // Pretend I/O. It resolves at once, so nothing here waits on real time.
  read(i) { return Promise.resolve(this.rows[i]); },
};

async function* rowsOf(source) {
  for (let i = 0; i < source.rows.length; i++) yield await source.read(i);
}

async function* feesOver(source, limit) {
  for await (const row of source) if (row.fee > limit) yield row.plate;
}

const collect = async (source) => {
  const out = [];
  for await (const v of source) out.push(v);
  return out;
};

const big = await collect(feesOver(rowsOf(store), 2000));
const all = await collect(feesOver(rowsOf(store), 100));

let reads = 0;
const metered = { rows: store.rows, read(i) { reads++; return Promise.resolve(this.rows[i]); } };
const lazily = feesOver(rowsOf(metered), 2000);
const firstOne = await lazily.next();
const readsAfterOne = reads;

console.log('over ₦2000 :', big);
console.log('everything :', all);
console.log('one value out cost', readsAfterOne, 'read(s)');

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 generator is consumed with for await', big, ['LAG-441-XA', 'ABJ-772-KM']);
test('and two of them compose like sync ones', all, ['LAG-441-XA', 'ABJ-772-KM', 'KAN-118-QQ']);
test('calling one returns an async iterator, not a promise', typeof rowsOf(store)[Symbol.asyncIterator], 'function');
test('its next() resolves to the usual result object', await rowsOf(store).next(), { value: { plate: 'LAG-441-XA', fee: 2500 }, done: false });
test('the pipeline is lazy: one value out, one read in', [firstOne.value, readsAfterOne], ['LAG-441-XA', 1]);
test('it has no Symbol.iterator, so for...of cannot take it', typeof rowsOf(store)[Symbol.iterator], 'undefined');
test('the last next reports done', (await collect(rowsOf(store))).length, 3);
test('an async generator that yields nothing is simply empty', await collect((async function* () {})()), []);

async function* is the two features stacked: await for waiting, yield for producing, and the result is a pull-based stream where each pull may take time. That is the right shape for anything that arrives in pieces — paged responses, lines of a file, messages off a socket — because the consumer sets the pace.

The laziness assertion is the practical consequence. feesOver(rowsOf(metered), 2000).next() performed exactly one read. An eager version would have fetched all three before the caller looked at any of them, which on a hundred-page API is a hundred requests for a value you may discard after the first.

Note the deliberate absence of Symbol.iterator. An async generator cannot pretend to be synchronous — its values are not available yet — so for...of and spread reject it outright rather than handing you promises. That refusal is a feature: the alternative is an array of pending promises that looks like data.

Answers JS Fundamental II 13 in the original set.

14 Page through a fake API with an async generator

hard

Hide pagination behind an async generator: the caller iterates items, never pages. Count the requests. Then take only the first three items and show that the third page was never fetched.

Stuck? Three hints, smallest first
  1. 1. Loop while there is a next page cursor, await the page, then yield* its items.
  2. 2. yield* over a plain array works inside an async generator too.
  3. 3. A takeAsync that returns early stops pulling, and the pull is what triggers the next request.

The answer

const PAGES = {
  1: { items: ['LAG-001', 'LAG-002'], next: 2 },
  2: { items: ['LAG-003', 'LAG-004'], next: 3 },
  3: { items: ['LAG-005'], next: null },
};

let requests = 0;
const api = { get(page) { requests++; return Promise.resolve(PAGES[page]); } };

async function* paged(api, from = 1) {
  let page = from;
  while (page !== null) {
    const body = await api.get(page);
    yield* body.items;              // the caller never sees a page boundary
    page = body.next;
  }
}

async function* takeAsync(source, n) {
  if (n <= 0) return;
  let i = 0;
  for await (const v of source) { yield v; if (++i === n) return; }
}

const collect = async (source) => {
  const out = [];
  for await (const v of source) out.push(v);
  return out;
};

const everything = await collect(paged(api));
const requestsForAll = requests;

requests = 0;
const firstThree = await collect(takeAsync(paged(api), 3));
const requestsForThree = requests;

const onePage = await collect(paged({ get: () => Promise.resolve({ items: [], next: null }) }));

console.log('all plates :', everything, 'in', requestsForAll, 'requests');
console.log('first three:', firstThree, 'in', requestsForThree, 'requests');

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('paging walks every page', everything, ['LAG-001', 'LAG-002', 'LAG-003', 'LAG-004', 'LAG-005']);
test('which took three requests', requestsForAll, 3);
test('taking three stops early', firstThree, ['LAG-001', 'LAG-002', 'LAG-003']);
test('so only two pages were fetched', requestsForThree, 2);
test('the consumer never sees a page boundary', firstThree.join(','), 'LAG-001,LAG-002,LAG-003');
test('a single empty page terminates cleanly', onePage, []);
test('yield* delegates to a sync iterable inside an async generator', typeof paged(api)[Symbol.asyncIterator], 'function');

This is the shape worth stealing. The pagination cursor, the request loop and the page envelope all stay inside the generator; the caller writes for await (const plate of paged(api)) and is done. Compare the alternative, where every caller maintains a while (nextPage) loop and gets it subtly wrong once.

requestsForThree === 2 is what backpressure looks like without a framework. The second page was fetched because the third item was asked for; the third page was not, because nothing asked. That property is why an async generator beats a callback that receives every page: the consumer's break is a signal the producer can act on.

Two things this version leaves out on purpose. There is no retry, so a failed page rejects the whole loop — compose it with exercise 22's retry around api.get. And there is no cancellation beyond break, which is exercise 15.

Answers JS Fundamental II 14 in the original set.

15 Cancel an async generator

hard

Write an infinite async generator that "subscribes" in a try and unsubscribes in a finally. Show that break out of for await unsubscribes, that return() does too, and that abandoning it does not.

Stuck? Three hints, smallest first
  1. 1. The same IteratorClose rule applies: leaving a for await early calls return() on the async iterator.
  2. 2. await gen.return(v) resolves to a done result once the finally has finished.
  3. 3. A generator you stop awaiting is simply parked for ever. Nothing collects it.

The answer

let cleanups = 0;

async function* subscription(log) {
  log.push('subscribe');
  try {
    let n = 1;
    while (true) {
      await Promise.resolve();          // pretend a message arrived
      yield 'event ' + n++;
    }
  } finally {
    cleanups++;
    log.push('unsubscribe');
  }
}

const breakLog = [];
const got = [];
for await (const event of subscription(breakLog)) {
  got.push(event);
  if (got.length === 2) break;
}

const returnLog = [];
const s = subscription(returnLog);
await s.next();
const ended = await s.return('cancelled');
const afterwards = await s.next();

const abandonedLog = [];
const abandoned = subscription(abandonedLog);
await abandoned.next();

console.log('events    :', got);
console.log('break     :', breakLog);
console.log('return()  :', JSON.stringify(ended), returnLog);
console.log('abandoned :', abandonedLog, '<- still subscribed, for ever');

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('two events arrived', got, ['event 1', 'event 2']);
test('breaking out ran the finally block', breakLog, ['subscribe', 'unsubscribe']);
test('return() resolves to a done result carrying your value', ended, { value: 'cancelled', done: true });
test('and it runs the finally too', returnLog, ['subscribe', 'unsubscribe']);
test('nothing comes out after cancelling', [afterwards.done, afterwards.value === undefined], [true, true]);
test('a generator you simply stop reading never cleans up', abandonedLog, ['subscribe']);
test('so exactly two of the three subscriptions were closed', cleanups, 2);

An infinite async generator is the natural shape for a subscription, and the finally is the unsubscribe. What makes it work is that the language calls return() for you on early exit, so the ordinary break a reader would write is also the cleanup.

cleanups === 2 is the honest accounting. Three subscriptions were opened; two were closed. The abandoned one is not leaked in the memory sense — it will be collected once nothing references it — but its finally never runs, so whatever it was holding on the other side stays held. There is no finaliser to save you.

The pattern that makes this safe in real code: whoever creates the generator owns closing it, and does so in its own finally. const it = subscription(); try { for await (...) {} } finally { await it.return(); } is verbose and correct. Calling return() twice is harmless, which is what makes the belt-and-braces version viable.

Answers JS Fundamental II 15 in the original set.

16 A rejection inside for await ends the loop

fair

Loop with for await over an array containing one rejected promise. Show that the loop throws and stops, that the values after it are never read, and write the version that reports every outcome instead.

Stuck? Three hints, smallest first
  1. 1. The rejection surfaces at the await, so an ordinary try/catch around the loop catches it.
  2. 2. Once it throws, the loop is over: nothing after the failing element is visited.
  3. 3. To survive it, do the awaiting yourself inside the loop body, one try per item.

The answer

const mixed = [
  Promise.resolve('till 1: ₦2,500'),
  Promise.reject(new Error('till 2 offline')),
  Promise.resolve('till 3: ₦4,000'),
];

const collected = [];
let caught = '';
try {
  for await (const line of mixed) collected.push(line);
} catch (err) {
  caught = err.message;
}

// One try per item, so a failure is data rather than an exit.
const settledEach = async (promises) => {
  const out = [];
  for (const p of promises) {
    try { out.push({ status: 'fulfilled', value: await p }); }
    catch (err) { out.push({ status: 'rejected', reason: err.message }); }
  }
  return out;
};

const everyOutcome = await settledEach(mixed);

console.log('got before the failure:', collected);
console.log('caught                :', caught);
console.log('every outcome         :', everyOutcome.map((r) => r.status));

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('the loop stops at the first rejection', collected, ['till 1: ₦2,500']);
test('and the rejection surfaces as a throw', caught, 'till 2 offline');
test('so the third value is never read', collected.length, 1);
test('handling each one keeps the loop going', everyOutcome.map((r) => r.status), ['fulfilled', 'rejected', 'fulfilled']);
test('and records why the middle one failed', everyOutcome[1].reason, 'till 2 offline');
test('a settled promise can be awaited again, so nothing was consumed', await mixed[0], 'till 1: ₦2,500');
test('a plain value in the list passes straight through', await settledEach(['plain', 7]).then((r) => r.map((x) => x.value)), ['plain', 7]);

for await is a loop with an await in it, so a rejection behaves exactly like a throw inside a for body: the loop ends and the exception propagates. That is usually right, and it is the same all-or-nothing decision Promise.all makes — which is why exercise 24 puts the four combinators side by side.

The interesting part is what you lose. collected holds the first result and the third is never read, even though its promise had already resolved before the loop began. The work was done; the value was thrown away with the loop.

settledEach is Promise.allSettled's behaviour written out by hand, and writing it out shows what it costs: the results are shaped, not raw, so every caller has to branch on status. That is the trade. Prefer the throwing loop when any failure means the whole operation is void, and the settled shape when each item stands alone — a batch upload of receipts, say, where four of five landing is a useful outcome.

Answers JS Fundamental II 16 in the original set.

17 Bridge a push API to a pull API

hard

Build an object you can push values into and for await values out of. It must work both ways round: values pushed before anyone reads are buffered, and a reader parked on next() is released by the next push or by close().

Stuck? Three hints, smallest first
  1. 1. Keep a buffer array and a single parked resolve function. At most one of the two is ever non-empty.
  2. 2. next(): if the buffer has something, resolve at once; if closed, report done; otherwise return a promise and remember its resolve.
  3. 3. push: if someone is parked, resolve them directly and skip the buffer.

The answer

function pushStream() {
  const buffer = [];
  let parked = null;
  let closed = false;
  return {
    push(value) {
      if (parked) { const release = parked; parked = null; release({ value, done: false }); }
      else buffer.push(value);
    },
    close() {
      closed = true;
      if (parked) { const release = parked; parked = null; release({ value: undefined, done: true }); }
    },
    [Symbol.asyncIterator]() {
      return {
        next: () => {
          if (buffer.length) return Promise.resolve({ value: buffer.shift(), done: false });
          if (closed) return Promise.resolve({ value: undefined, done: true });
          return new Promise((resolve) => { parked = resolve; });
        },
      };
    },
  };
}

const collect = async (source) => {
  const out = [];
  for await (const v of source) out.push(v);
  return out;
};

// Pushed first, read afterwards: the buffer path.
const early = pushStream();
early.push('a');
early.push('b');
early.close();
const buffered = await collect(early);

// Read first, pushed afterwards: the parked path.
const live = pushStream();
const reading = collect(live);
live.push('x');
live.push('y');
live.close();
const streamed = await reading;

// A reader parked with nothing coming is released by close().
const idle = pushStream();
const pending = collect(idle);
idle.close();
const nothing = await pending;

// And the buffer is still drained after close, which is a real wrinkle.
const late = pushStream();
late.close();
late.push('late');
const afterClose = await collect(late);

console.log('buffered :', buffered);
console.log('streamed :', streamed);
console.log('idle     :', nothing);
console.log('pushed after close:', afterClose, '<- still delivered');

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('values pushed before anyone reads are buffered', buffered, ['a', 'b']);
test('a reader parked on next() is released by the next push', streamed, ['x', 'y']);
test('closing while a reader is parked ends the loop with nothing', nothing, []);
test('this version still drains the buffer after close, which is a bug worth seeing', afterClose, ['late']);
test('it is a pull API on the outside', typeof early[Symbol.asyncIterator], 'function');
test('and nothing polls: next() returns a promise that someone else resolves', typeof pushStream()[Symbol.asyncIterator]().next().then, 'function');
test('a stream closed before use is simply empty', await collect((() => { const s = pushStream(); s.close(); return s; })()), []);

Everything that arrives from outside your program is a push: an event, a message, a webhook. Everything that reads nicely is a pull: for await. This little object is the adapter, and the whole of it is the invariant that the buffer and the parked resolver are never both occupied — if someone is waiting, hand the value straight over; if not, keep it.

The fourth assertion is deliberately a failure. next() checks the buffer before it checks closed, so a value pushed after close() is still delivered. Whether that is a bug depends on what you meant by close — "no more will be accepted" or "stop after what you have" — and the lesson is that you have to decide, because the naive ordering decides for you.

What this leaves out is backpressure. A producer that pushes faster than the consumer reads grows the buffer without limit, which is how a memory graph goes vertical. A real implementation caps the buffer and either drops, blocks the producer by returning a promise from push, or errors. The circular buffer in exercise 87 is the drop-oldest version of that decision.

Answers JS Fundamental II 17 in the original set.

18 When both iterator symbols are present

fair

Give one object both a [Symbol.iterator] and a [Symbol.asyncIterator] that yield different values. Work out which one for...of, spread and for await each choose.

Stuck? Three hints, smallest first
  1. 1. Spread and for...of only ever look at Symbol.iterator.
  2. 2. for await looks for Symbol.asyncIterator first and only falls back when it is absent.
  3. 3. Make the two yield distinguishable values, so the answer is visible rather than argued about.

The answer

const both = {
  [Symbol.iterator]() {
    let i = 0;
    return { next: () => (i < 2 ? { value: 'sync ' + i++, done: false } : { value: undefined, done: true }) };
  },
  [Symbol.asyncIterator]() {
    let i = 0;
    return {
      next: () => Promise.resolve(i < 2 ? { value: 'async ' + i++, done: false } : { value: undefined, done: true }),
    };
  },
};

const collect = async (source) => {
  const out = [];
  for await (const v of source) out.push(v);
  return out;
};

const spread = [...both];
const syncLoop = [];
for (const v of both) syncLoop.push(v);
const asyncLoop = await collect(both);
const syncOnly = await collect({ [Symbol.iterator]: both[Symbol.iterator] });
const neither = (() => { try { return [...{}]; } catch (e) { return e.constructor.name; } })();

console.log('spread     :', spread);
console.log('for...of   :', syncLoop);
console.log('for await  :', asyncLoop, '<- the async one wins');
console.log('sync only, awaited:', syncOnly);

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('spread uses Symbol.iterator', spread, ['sync 0', 'sync 1']);
test('so does for...of', syncLoop, ['sync 0', 'sync 1']);
test('for await prefers Symbol.asyncIterator when it exists', asyncLoop, ['async 0', 'async 1']);
test('and falls back to the sync one when it does not', syncOnly, ['sync 0', 'sync 1']);
test('an object with neither is not iterable at all', neither, 'TypeError');
test('both symbols describe themselves by name', [String(Symbol.iterator), String(Symbol.asyncIterator)], ['Symbol(Symbol.iterator)', 'Symbol(Symbol.asyncIterator)']);
test('an async generator supplies only the async one', [typeof (async function* () {})()[Symbol.asyncIterator], typeof (async function* () {})()[Symbol.iterator]], ['function', 'undefined']);
test('a sync generator supplies only the sync one', [typeof (function* () {})()[Symbol.iterator], typeof (function* () {})()[Symbol.asyncIterator]], ['function', 'undefined']);

Two protocols, two keys, and the choice is made by the consumer, not the producer. That is why an object can offer both: a caller who can wait gets the async view, a caller who cannot gets the sync one. Node's streams do something close to this.

It is also a way to be quietly inconsistent. Nothing checks that the two agree, so an object whose sync iterator yields stale cached values and whose async iterator fetches fresh ones will behave differently depending on which loop you reach for, with nothing in the code to warn you. If you implement both, make them yield the same values or give them different names.

The fallback in the fourth assertion is worth remembering the other way round too: for await works on anything iterable, including an array or a string, so it is safe as the general-purpose loop when you do not know what you were handed. The cost is one microtask per element even when nothing is asynchronous.

Answers JS Fundamental II 18 in the original set.

19 A promise built by hand around a callback

fair

Wrap an error-first callback API in a promise with the new Promise constructor. Then answer four questions about the executor: when does it run, what happens if it throws, what happens if you resolve twice, and what happens if you reject after resolving?

Stuck? Three hints, smallest first
  1. 1. The executor runs synchronously, right there in the constructor. Only the settlement is deferred.
  2. 2. A throw inside the executor is turned into a rejection for you.
  3. 3. A promise settles once. Every later resolve or reject is a no-op, silently.

The answer

const bank = {
  balances: { 'LAG-01': 45000, 'ABJ-02': 12000 },
  lookup(account, callback) {
    // Deliberately asynchronous: the callback never fires in the same tick.
    Promise.resolve().then(() =>
      account in this.balances
        ? callback(null, this.balances[account])
        : callback(new Error('no such account: ' + account))
    );
  },
};

const balanceOf = (account) =>
  new Promise((resolve, reject) => {
    bank.lookup(account, (err, value) => (err ? reject(err) : resolve(value)));
  });

let executorRan = false;
new Promise(() => { executorRan = true; });

const order = [];
const watching = balanceOf('LAG-01').then((v) => order.push('resolved ' + v));
order.push('the line after the call');
await watching;

const missing = await balanceOf('nope').then(() => 'resolved', (e) => e.message);
const twice = await new Promise((resolve) => { resolve('first'); resolve('second'); });
const thenRejected = await new Promise((resolve, reject) => { resolve('kept'); reject(new Error('ignored')); });
const blewUp = await new Promise(() => { throw new Error('executor blew up'); }).catch((e) => e.message);

console.log('balance   :', await balanceOf('ABJ-02'));
console.log('missing   :', missing);
console.log('order     :', order);
console.log('settled twice:', twice, ' rejected after resolving:', thenRejected);

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 wrapper resolves with the value', await balanceOf('LAG-01'), 45000);
test('and rejects with the error', missing, 'no such account: nope');
test('the executor runs synchronously, inside the constructor', executorRan, true);
test('but settlement is always asynchronous, so the next line runs first', order, ['the line after the call', 'resolved 45000']);
test('resolving twice keeps the first value', twice, 'first');
test('rejecting after resolving is ignored', thenRejected, 'kept');
test('a throw inside the executor becomes a rejection', blewUp, 'executor blew up');
test('and the promise is a real promise, not a thenable', (await balanceOf('LAG-01').then(() => balanceOf('LAG-01'))) instanceof Promise, false);

new Promise is for exactly one job: wrapping something that is not already a promise. If you are writing it around a function that already returns one, you have written the deferred anti-pattern and added a layer that can only lose errors.

The four questions are the whole contract. The executor is synchronous — so any setup you do there has already happened by the time the constructor returns, and a throw in it is caught and converted, which is why you never need a try around your own reject call. Settlement is asynchronous — the fourth assertion shows the line after the call running first, which is guaranteed even if the value was available immediately. And settlement is final — the fifth and sixth assertions show a second resolve and a late reject being dropped without a word, which makes a double-callback bug in the wrapped API invisible rather than fatal.

The last assertion is a small trap of its own: await unwraps, so the value inside is a number and not a promise. Returning a promise from a then handler flattens it rather than nesting it, which is exercise 30.

Answers JS Fundamental II 19 in the original set.

20 Write promisify, and find the this it loses

fair

Write a general promisify(fn) for error-first callback functions. Then promisify a method and find out why it fails, and what fixes it.

Stuck? Three hints, smallest first
  1. 1. Collect the caller’s arguments with a rest parameter and append your own callback.
  2. 2. The wrapper calls fn(...) as a plain function, so this inside it is not the object it came from.
  3. 3. fn.bind(obj) before promisifying, or pass the receiver in.

The answer

const promisify = (fn) => (...args) =>
  new Promise((resolve, reject) => {
    fn(...args, (err, value) => (err ? reject(err) : resolve(value)));
  });

const readFee = (plate, callback) => {
  const fees = { 'LAG-441-XA': 2500, 'ABJ-772-KM': 4000 };
  Promise.resolve().then(() =>
    plate in fees ? callback(null, fees[plate]) : callback(new Error('unknown plate: ' + plate))
  );
};

const feeFor = promisify(readFee);

// A METHOD is the problem case: it needs its receiver.
const till = {
  total: 9000,
  read(callback) { callback(null, this.total); },
};
const detachedFails = (() => {
  try { const loose = till.read; loose(() => {}); return 'no throw'; }
  catch (e) { return e.constructor.name; }
})();
const readTotal = promisify(till.read.bind(till));

console.log('fee      :', await feeFor('LAG-441-XA'));
console.log('unknown  :', await feeFor('XXX').then(() => 'resolved', (e) => e.message));
console.log('detached method:', detachedFails);
console.log('bound then promisified:', await readTotal());

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 promisified function resolves with the callback value', await feeFor('LAG-441-XA'), 2500);
test('and rejects with the callback error', await feeFor('XXX').then(() => 'resolved', (e) => e.message), 'unknown plate: XXX');
test('a detached method loses this, so promisify alone is not enough', detachedFails, 'TypeError');
test('binding the receiver first fixes it', await readTotal(), 9000);
test('the wrapper reports arity 0, because of the rest parameter', feeFor.length, 0);
test('extra callback arguments beyond the first value are dropped', await promisify((cb) => cb(null, 1, 2))(), 1);
test('a falsy error still counts as success', await promisify((cb) => cb(undefined, 'fine'))(), 'fine');
test('but a callback that reports 0 as its error would reject nothing', await promisify((cb) => cb(0, 'suspicious'))(), 'suspicious');

The rest-and-append trick is the whole of promisify: take whatever the caller passed, put your callback last, and let the promise constructor do the rest. Node's util.promisify is this plus a symbol so a library can supply its own custom version.

The this failure is the one that catches people, and it is not really about promisify at all — const loose = till.read; loose(cb) fails the same way. Extracting a method from an object severs it from its receiver, and in strict mode this is undefined rather than the global object, so this.total throws instead of quietly reading nothing. Exercise 72 is this trap on its own.

The last two assertions are the contract's soft spot. "Error-first" is a convention enforced by nothing, so a truthy check is all promisify can do. A callback that hands back 0 or ‘’ as its error is treated as success, and one that hands back several values loses everything after the first. Both are why promisify is a bridge to be crossed once, at the edge of the codebase, and not a pattern to build on.

Answers JS Fundamental II 20 in the original set.

21 Map with a concurrency limit

hard

Write mapLimit(items, limit, worker) that runs at most limit workers at once, keeps the results in input order, and rejects like Promise.all if a worker throws. Measure the peak concurrency and prove it never exceeds the limit.

Stuck? Three hints, smallest first
  1. 1. Start limit identical runners. Each one takes the next unclaimed index until there are none left.
  2. 2. A shared next counter, read and incremented in one synchronous step, is all the co-ordination you need.
  3. 3. Write into results[index], not with push, or the order follows completion instead of input.

The answer

async function mapLimit(items, limit, worker) {
  const results = new Array(items.length);
  let next = 0;
  const runner = async () => {
    while (next < items.length) {
      const index = next++;                 // claimed synchronously: no two runners share one
      results[index] = await worker(items[index], index);
    }
  };
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runner));
  return results;
}

let active = 0;
let peak = 0;
const upload = async (n) => {
  active++;
  peak = Math.max(peak, active);
  await Promise.resolve();
  await Promise.resolve();
  active--;
  return n * 2;
};

const doubled = await mapLimit([1, 2, 3, 4, 5, 6, 7], 3, upload);

const sequential = [];
await mapLimit([1, 2, 3], 1, async (n) => {
  sequential.push('start ' + n);
  await Promise.resolve();
  sequential.push('end ' + n);
});

const failed = await mapLimit([1, 2, 3], 2, async (n) => {
  if (n === 2) throw new Error('item 2 failed');
  return n;
}).then(() => 'resolved', (e) => e.message);

console.log('results :', doubled);
console.log('peak    :', peak, 'of a limit of 3');
console.log('limit 1 :', sequential);
console.log('failure :', failed);

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 item is processed', doubled, [2, 4, 6, 8, 10, 12, 14]);
test('results stay in input order', doubled.at(-1), 14);
test('never more than the limit at once', peak, 3);
test('a limit of one is strictly sequential', sequential, ['start 1', 'end 1', 'start 2', 'end 2', 'start 3', 'end 3']);
test('a limit above the item count starts only as many runners as there are items', await mapLimit([1, 2], 10, upload), [2, 4]);
test('an empty list resolves without starting a worker', await mapLimit([], 3, upload), []);
test('the worker sees the index too', await mapLimit(['a', 'b'], 2, async (v, i) => v + i), ['a0', 'b1']);
test('one failure rejects the whole thing, like Promise.all', failed, 'item 2 failed');

The trick is that the runners are identical and stateless: each one loops "claim an index, do it, repeat" and they all stop when the indexes run out. There is no scheduler, no queue object and no bookkeeping of which worker is free. const index = next++ is atomic in the only sense that matters here — JavaScript runs one thing at a time, so no two runners can read the same value.

peak === 3 is the assertion the whole exercise exists for. Without it this is a plausible-looking function you have to trust; with it, the claim is checked. Note how the measurement works: increment on entry, take the maximum, decrement on exit. That is the shape of every concurrency probe you will write.

Two honest limitations. Failure behaves like Promise.all, so the first rejection wins and the runners already in flight keep going — there is no cancellation, only an ignored result. And "in order" applies to the results, not the completions: item 7 may finish before item 3, and results[6] = ... is what keeps the array honest. Swap it for results.push and the bug survives every test that does not check order.

Answers JS Fundamental II 21 in the original set.

22 Retry with backoff, a cap and a time budget

hard

Retry a flaky call with exponentially growing waits, a maximum single wait, and a total time budget it refuses to exceed. When it gives up, the error it throws must carry the last failure as its cause. Also Basic 50, which is the plain backoff; this one adds the cap, the budget and the cause.

Stuck? Three hints, smallest first
  1. 1. Take the clock as an argument. The fake one records each wait and jumps forward instantly, so the schedule is assertable.
  2. 2. Math.min(capMs, baseMs * 2 ** (attempt - 1)) is the capped wait.
  3. 3. Before waiting, check whether the wait would take you past the budget. If it would, stop now rather than after.

The answer

// Simulated time. wait() records the delay and moves the clock instantly, so
// nothing here sleeps and the schedule can be asserted exactly.
function 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(); },
  };
}

async function retry(task, { attempts = 4, baseMs = 200, capMs = Infinity, budgetMs = Infinity, clock }) {
  let last;
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try { return { value: await task(attempt), attempt }; }
    catch (err) {
      last = err;
      if (attempt === attempts) break;
      const wait = Math.min(capMs, baseMs * 2 ** (attempt - 1));
      if (clock.time + wait > budgetMs) break;     // no point waiting past the budget
      await clock.wait(wait);
    }
  }
  throw new Error(`gave up after ${attempts} attempt(s)`, { cause: last });
}

const clock = fakeClock();
let calls = 0;
const flaky = async () => {
  calls++;
  if (calls < 3) throw new Error('502 from the gateway');
  return 'receipt LAG-0007';
};
const good = await retry(flaky, { baseMs: 200, clock });

const capped = fakeClock();
await retry(async () => { throw new Error('down'); }, { attempts: 5, baseMs: 100, capMs: 300, clock: capped }).catch(() => {});

const tight = fakeClock();
const gaveUp = await retry(async () => { throw new Error('down'); }, { attempts: 9, baseMs: 100, budgetMs: 350, clock: tight }).catch((e) => e);

const clean = fakeClock();
await retry(async () => 'first time', { clock: clean });

console.log('succeeded  :', good, 'after', clock.waits, 'ms of waiting');
console.log('capped     :', capped.waits);
console.log('budgeted   :', tight.waits, 'stopped at', tight.time, 'ms');
console.log('cause      :', gaveUp.cause.message);

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('it succeeded on the third attempt', good, { value: 'receipt LAG-0007', attempt: 3 });
test('after two waits, which doubled', clock.waits, [200, 400]);
test('600ms of simulated time, none of it real', clock.time, 600);
test('capMs stops the doubling running away', capped.waits, [100, 200, 300, 300]);
test('the budget stops it before the wait that would exceed it', tight.waits, [100, 200]);
test('the final error keeps the last failure as its cause', gaveUp.cause.message, 'down');
test('and says how many attempts were configured', gaveUp.message, 'gave up after 9 attempt(s)');
test('a task that works first time never waits at all', [clean.waits, clean.time], [[], 0]);

Basic 50 covers the doubling; the additions here are the ones that matter in production. Uncapped exponential backoff reaches an hour by attempt 15, so capMs turns the curve into a plateau. And a caller waiting on a request does not care how many attempts you configured, only how long it takes — so the budget is checked before the wait, which is why the third wait of 400ms is never taken rather than being cut short.

cause is the part most retry helpers get wrong. Throwing new Error(‘gave up’) alone destroys the only useful information in the failure: why. The second argument to the Error constructor attaches the original, so a log line can print "gave up after 9 attempts" and the 502 underneath it.

One thing this deliberately does not do: jitter. Real backoff multiplies each wait by a random factor, because a thousand clients that failed in the same second will otherwise retry in the same second and turn a blip into an outage. It is left out here only because a random wait cannot be asserted — in production, add it.

Answers JS Fundamental II 22 in the original set.

23 A timeout wrapper, and the loser it does not cancel

hard

Write withTimeout(work, ms, clock) that rejects if the work has not settled in time. Use an injected clock so the boundary is exact. Then show two things the race does not do: it does not fire early, and it does not stop the loser.

Stuck? Three hints, smallest first
  1. 1. Promise.race between the work and a timer that rejects when it fires.
  2. 2. The clock hands out promises that resolve only when you advance it past their deadline.
  3. 3. A race settles with the first outcome. The other promise is not cancelled, it is merely ignored.

The answer

// A clock you drive by hand: timers fire only when you advance past them.
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 t of timers.filter((t) => t.at <= now)) {
        timers.splice(timers.indexOf(t), 1);
        t.resolve();
      }
      for (let i = 0; i < 8; i++) await Promise.resolve();   // let the waiters run
    },
  };
}

const withTimeout = (work, ms, clock) =>
  Promise.race([
    work,
    clock.wait(ms).then(() => { throw new Error(`timed out after ${ms}ms`); }),
  ]);

const clock = manualClock();
const wonByWork = await withTimeout(Promise.resolve('paid'), 1000, clock);
const leakedTimer = clock.pending;

let finish;
const slow = new Promise((resolve) => { finish = resolve; });
const running = withTimeout(slow, 5000, clock).then((v) => 'value ' + v, (e) => e.message);
await clock.advance(5000);
const timedOut = await running;
finish('late receipt');
const workStillFinished = await slow;

const edge = manualClock();
const onTheBoundary = withTimeout(new Promise(() => {}), 100, edge).catch((e) => e.message);
await edge.advance(99);
const notYet = edge.pending;
await edge.advance(1);
const fired = await onTheBoundary;

console.log('work won  :', wonByWork, '- timers still pending:', leakedTimer);
console.log('timed out :', timedOut);
console.log('yet the work finished anyway:', workStillFinished);
console.log('at 99ms pending:', notYet, ' at 100ms:', fired);

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 work won the race', wonByWork, 'paid');
test('but the loser is not cancelled: the timer is still pending', leakedTimer, 1);
test('when the timer wins, the caller gets a rejection', timedOut, 'timed out after 5000ms');
test('yet the work carries on and completes, unaborted', workStillFinished, 'late receipt');
test('at 99ms the timer has not fired', notYet, 1);
test('at exactly 100ms it does', fired, 'timed out after 100ms');
test('and the clock reads 100, with no real time spent', edge.time, 100);
test('a timeout of 0 fires as soon as the clock moves at all', await (async () => { const c = manualClock(); const p = withTimeout(new Promise(() => {}), 0, c).catch((e) => e.message); await c.advance(0); return p; })(), 'timed out after 0ms');

A timeout is a race, and a race has no opinion about the loser. The second and fourth assertions are the two halves of that: after the work wins, the timer is still sitting in the queue; after the timer wins, the work runs to completion and its result is discarded. Neither is stopped, because a promise is a value that will arrive, not a task you can call off.

Both halves cost something real. A leaked timer keeps a callback and its closure alive — with setTimeout in Node it also keeps the process from exiting — so production code clears it in a finally. And an unaborted request keeps a socket and a database transaction open behind a caller who has already given up, which is how a slow dependency turns into an exhausted connection pool. Cancelling the underlying work needs cooperation from the work itself: an AbortSignal, or the token in exercise 33.

The boundary assertions are why the clock is injected rather than real. notYet === 1 at 99ms and fired at exactly 100ms is a statement about the comparison being <=, and with a real timer it would be a statement about how busy the machine was.

Answers JS Fundamental II 23 in the original set.

24 all, allSettled, any and race on the same inputs

hard

Run all four combinators over the same three promises, one of which rejects, and account for every difference. Then check the four empty-array cases, which do not agree either.

Stuck? Three hints, smallest first
  1. 1. all wants every success; any wants one; allSettled wants the report; race wants the first, either way.
  2. 2. any rejects with an AggregateError whose errors array holds every reason.
  3. 3. For the empty array the four disagree: two resolve, one rejects, one never settles at all.

The answer

const tills = () => [
  Promise.resolve('till 1: ₦2,500'),
  Promise.reject(new Error('till 2 offline')),
  Promise.resolve('till 3: ₦4,000'),
];

const fromAll = await Promise.all(tills()).then((v) => ({ ok: v }), (e) => ({ error: e.message }));
const fromSettled = (await Promise.allSettled(tills())).map((r) =>
  r.status === 'fulfilled' ? r.value : 'ERR: ' + r.reason.message
);
const fromAny = await Promise.any(tills());
const fromRace = await Promise.race(tills()).then((v) => v, (e) => 'rejected: ' + e.message);

const raceCanReject = await Promise.race([
  Promise.reject(new Error('first to settle')),
  Promise.resolve('a success that was available'),
]).then(() => 'resolved', (e) => e.message);

const allRejected = await Promise.any([Promise.reject(new Error('a')), Promise.reject(new Error('b'))])
  .catch((e) => [e.constructor.name, e.errors.map((x) => x.message)]);

let emptyRace = 'still pending';
Promise.race([]).then(() => { emptyRace = 'settled'; });
for (let i = 0; i < 6; i++) await Promise.resolve();

console.log('all       :', fromAll);
console.log('allSettled:', fromSettled);
console.log('any       :', fromAny);
console.log('race      :', fromRace);
console.log('empties   : all', await Promise.all([]), ' race:', emptyRace);

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('all rejects on the first failure and discards the successes it had', fromAll, { error: 'till 2 offline' });
test('allSettled reports every outcome instead', fromSettled, ['till 1: ₦2,500', 'ERR: till 2 offline', 'till 3: ₦4,000']);
test('any ignores rejections and takes the first success', fromAny, 'till 1: ₦2,500');
test('race takes the first to SETTLE, whichever way it went', fromRace, 'till 1: ₦2,500');
test('so race can reject even when a success was available', raceCanReject, 'first to settle');
test('any with nothing but failures gives an AggregateError listing them all', allRejected, ['AggregateError', ['a', 'b']]);
test('all of an empty array resolves at once', await Promise.all([]), []);
test('but any of an empty array rejects', await Promise.any([]).catch((e) => e.constructor.name), 'AggregateError');
test('and race of an empty array never settles at all', emptyRace, 'still pending');

Pick by the question you are asking. All of these must work is all — three parallel lookups feeding one page. Tell me how each one went is allSettled — a batch of independent uploads. Any one will do is any — the same query against three mirrors. Whatever happens first is race, and in practice that means a timeout.

The first assertion is the one that bites. all rejects the moment anything rejects, and the two successes that had already arrived are simply gone — you cannot recover them from the rejection. If partial success is useful to you, all is the wrong tool no matter how convenient the destructuring is.

race rejecting on the first settlement rather than the first success is the difference between it and any, and the fifth assertion is that in one line. Used as a timeout that is exactly right. Used as "fastest mirror wins" it is wrong, because the fastest mirror to fail wins too.

The empty cases are not a curiosity. all([]) and allSettled([]) resolve, any([]) rejects — vacuously, since no promise succeeded — and race([]) hangs for ever with no error and no warning. A list you built from a filter that happened to match nothing is how you meet that one.

Answers JS Fundamental II 24 in the original set.

25 Where a rejection goes when nobody is looking

fair

Reject a promise with no handler attached, then attach one later and show it still arrives. Work through what then and catch return, what a throw inside each becomes, and why a rejection can pass several links untouched.

Stuck? Three hints, smallest first
  1. 1. A rejection is a stored state, not an event. Nothing is thrown at the point of rejection.
  2. 2. catch(fn) is then(undefined, fn), and it returns a new, fulfilled promise if the handler returns normally.
  3. 3. then with only a success handler has nothing to catch with, so the rejection skips it.

The answer

const failed = Promise.reject(new Error('nobody is listening'));

let picked = '';
await Promise.resolve();                          // a turn goes by, still unhandled
await failed.catch((e) => { picked = e.message; });

const secondLook = await failed.catch((e) => e.message);
const recovered = await failed.catch(() => 'recovered');
const carriedOn = await failed.catch(() => 0).then((n) => n + 1);
const throwInThen = await Promise.resolve(1).then(() => { throw new Error('thrown in then'); }).catch((e) => e.message);
const skipped = await Promise.reject(new Error('skipped past')).then(() => 'not run').catch((e) => e.message);
const throwInCatch = await Promise.reject(new Error('a')).catch(() => { throw new Error('b'); }).catch((e) => e.message);
const notAnError = await Promise.reject('just a string').catch((e) => [typeof e, e]);

console.log('picked up later :', picked);
console.log('and again       :', secondLook);
console.log('catch recovers  :', recovered, '-> chain continues:', carriedOn);
console.log('rejecting with a non-Error:', notAnError);

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 rejection can be picked up later in the same tick', picked, 'nobody is listening');
test('the promise stays rejected, so a second handler sees it too', secondLook, 'nobody is listening');
test('catch returns a new promise that is FULFILLED with the handler result', recovered, 'recovered');
test('so the chain carries on after it', carriedOn, 1);
test('a throw inside then becomes the next rejection', throwInThen, 'thrown in then');
test('then with only a success handler passes a rejection straight through', skipped, 'skipped past');
test('a throw inside catch needs another catch', throwInCatch, 'b');
test('rejecting with a non-Error works, and loses the stack', notAnError, ['string', 'just a string']);

A rejection is a state a promise is in, not an event that fires. Nothing is thrown when Promise.reject runs; the reason sits there until something asks. That is why attaching a handler a turn later still works, and why the same promise can be handled twice.

What the runtime adds on top is a warning, not a mechanism: if the microtask queue drains and a rejected promise has still never been handled, Node prints an unhandled rejection and, since v15, exits non-zero. Browsers fire unhandledrejection on window. So "attach the handler later" works within the tick and is a bug across ticks.

The third assertion is the one people misread. catch does not mark a promise handled and leave it rejected — it returns a new promise that is fulfilled with whatever the handler returned. Which is exactly why .catch(() => {}) silently converts a failure into a success carrying undefined, and why the sixth assertion matters: a then without a second argument cannot swallow anything, so a rejection travels down a long chain untouched until it meets a real handler.

The last one is a habit worth keeping. throw new Error(...), never throw ‘string’. A string reason has no stack, no name and no cause, and every log line downstream is worse for it.

Answers JS Fundamental II 25 in the original set.

26 Why await in a loop serialises

hard

Run the same three async tasks two ways — await inside a for...of, and Promise.all over a map — and log every start and end. Compute the peak concurrency of each. Then find the case where the loop is the correct shape, and the one where forEach is neither.

Stuck? Three hints, smallest first
  1. 1. Have each task log on entry and on exit, then read the interleaving off the log.
  2. 2. Peak concurrency: walk the log, add one for a start and subtract one for an end, keep the maximum.
  3. 3. forEach ignores the promise its callback returns, so nothing awaits anything.

The answer

const makeTask = (log) => async (name, rounds) => {
  log.push('start ' + name);
  for (let i = 0; i < rounds; i++) await Promise.resolve();
  log.push('end ' + name);
  return name.toUpperCase();
};

const peakOf = (log) => {
  let inFlight = 0;
  let peak = 0;
  for (const line of log) {
    if (line.startsWith('start')) peak = Math.max(peak, ++inFlight);
    else inFlight--;
  }
  return peak;
};

const seqLog = [];
const seqTask = makeTask(seqLog);
const seqOut = [];
for (const name of ['a', 'b', 'c']) seqOut.push(await seqTask(name, 2));

const parLog = [];
const parTask = makeTask(parLog);
const parOut = await Promise.all(['a', 'b', 'c'].map((name) => parTask(name, 2)));

// When each step needs the one before it, the loop is right.
const chain = [];
let carry = 1;
for (const factor of [2, 3, 4]) { carry = await Promise.resolve(carry * factor); chain.push(carry); }

// forEach does not await, so this collects nothing at all.
const dropped = [];
['a', 'b'].forEach(async (v) => { await Promise.resolve(); dropped.push(v); });
const droppedNow = [...dropped];

console.log('sequential:', seqLog);
console.log('parallel  :', parLog);
console.log('peaks     :', peakOf(seqLog), 'against', peakOf(parLog));
console.log('forEach right after the loop:', droppedNow);

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 in a loop runs them one after another', seqLog, ['start a', 'end a', 'start b', 'end b', 'start c', 'end c']);
test('Promise.all starts all three before any of them finishes', parLog, ['start a', 'start b', 'start c', 'end a', 'end b', 'end c']);
test('peak concurrency: one against three', [peakOf(seqLog), peakOf(parLog)], [1, 3]);
test('both produce the same results in the same order', [seqOut, parOut], [['A', 'B', 'C'], ['A', 'B', 'C']]);
test('so the difference is time, not correctness, for independent work', seqOut.join('') === parOut.join(''), true);
test('when each step needs the last, the loop is the correct shape', chain, [2, 6, 24]);
test('forEach ignores the promise, so nothing has happened yet', droppedNow, []);
test('though the pushes do land eventually, unordered and unawaited', await (async () => { await Promise.resolve(); await Promise.resolve(); return dropped.length; })(), 2);

await means "stop this function here". In a loop that is once per iteration, so n independent requests of 200ms each take 200n milliseconds, and the log shows why: end a before start b. Promise.all over a map starts all of them in the synchronous pass and then waits once, so the same work takes as long as the slowest one.

The peak-concurrency assertion is the whole diagnosis in one number. One against three. That is also the reason to be careful: Promise.all over ten thousand items opens ten thousand connections at once, which is a different outage. Exercise 21's mapLimit is the middle ground, and in production it is nearly always the right answer.

The sixth assertion is the exception that keeps the loop honest. When each step consumes the previous result, there is nothing to parallelise and await in a loop is not a mistake — it is the only correct shape. Rewriting that as Promise.all would compute all three from the same starting value.

forEach is in a category of its own. It has no return value and does not look at the one the callback gives it, so an async callback returns a promise into the void: the loop finishes instantly, the work happens later, and errors inside become unhandled rejections. Use for...of to serialise or map plus Promise.all to parallelise, and never forEach with async.

Answers JS Fundamental II 26 in the original set.

27 An async queue that survives a failure

hard

Build a queue that runs added tasks strictly one at a time in the order they arrived. Each add hands back a promise for its own task's result. A task that throws must reject only its own caller and must not block the queue.

Stuck? Three hints, smallest first
  1. 1. Keep a tail promise. Each new task chains off it, and becomes the new tail.
  2. 2. The tail must be built from a chain that swallows failures, or one rejection stops everything after it.
  3. 3. The promise you return to the caller is not the same promise you keep as the tail.

The answer

class AsyncQueue {
  #tail = Promise.resolve();
  #pending = 0;

  get pending() { return this.#pending; }

  add(task) {
    this.#pending++;
    const result = this.#tail.then(() => task());
    // The tail must absorb failures, or one bad task blocks the queue for ever.
    this.#tail = result.then(
      () => { this.#pending--; },
      () => { this.#pending--; }
    );
    return result;
  }

  drain() { return this.#tail; }
}

const log = [];
const step = (name, rounds) => async () => {
  log.push('start ' + name);
  for (let i = 0; i < rounds; i++) await Promise.resolve();
  log.push('end ' + name);
  return name;
};

const q = new AsyncQueue();
const results = await Promise.all([q.add(step('a', 3)), q.add(step('b', 1)), q.add(step('c', 0))]);

const failLog = [];
const q2 = new AsyncQueue();
const bad = q2.add(async () => { failLog.push('bad'); throw new Error('kaboom'); }).catch((e) => e.message);
const good = q2.add(async () => { failLog.push('good'); return 'ran anyway'; });
const outcome = [await bad, await good];

const counting = new AsyncQueue();
counting.add(async () => 1);
counting.add(async () => 2);
const whileBusy = counting.pending;
await counting.drain();
const whenDrained = counting.pending;

const syncLog = [];
const q3 = new AsyncQueue();
q3.add(() => { syncLog.push('sync task'); return 'done'; });
const beforeDrain = [...syncLog];
await q3.drain();

console.log('order    :', log);
console.log('results  :', results);
console.log('failure  :', outcome, failLog);
console.log('pending  :', whileBusy, '->', whenDrained);

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('tasks run one at a time, in the order they were added', log, ['start a', 'end a', 'start b', 'end b', 'start c', 'end c']);
test('each add() gives back its own result', results, ['a', 'b', 'c']);
test('a failure is reported to its own caller only', outcome, ['kaboom', 'ran anyway']);
test('and the queue carried on past it', failLog, ['bad', 'good']);
test('pending counts what is outstanding', whileBusy, 2);
test('and drops to zero once drained', whenDrained, 0);
test('even a synchronous task is deferred to a microtask', [beforeDrain, syncLog], [[], ['sync task']]);

The queue is one promise. tail is "everything added so far has finished", and adding a task means chaining off it and replacing it. No array of jobs, no loop, no flag saying whether it is running.

The subtlety is that add maintains two promises from one task. result is what the caller gets, and it rejects if the task throws. The new tail is result.then(dec, dec), which has a handler on both sides and therefore always fulfils. Keep result as the tail instead and the third assertion fails hard: the rejection propagates into every task chained after it and the queue is dead for the rest of the process. That is the bug this exercise exists to show.

It also means a queue is a semaphore with one permit, and if you have written exercise 29 you can build this from it in three lines. Keep them apart when the two-promise trick is what you need to remember.

The last assertion is a promise property, not a queue one: this.#tail.then(() => task()) defers even an entirely synchronous task to a microtask. Consistent, and occasionally surprising when you expected the push to have happened by the next line.

Answers JS Fundamental II 27 in the original set.

28 A mutex, and the lost update it prevents

hard

Show a lost update: two withdrawals that read a balance, await something, and write back, so one overwrites the other. Then write a mutex and prove both withdrawals land. It must release the lock even when the critical section throws.

Stuck? Three hints, smallest first
  1. 1. Read-then-await-then-write is not atomic. Between the read and the write, other code runs.
  2. 2. The mutex is a chain of promises: each acquirer waits for the previous holder’s release.
  3. 3. try { ... } finally { release(); } is the only safe shape.

The answer

function createMutex() {
  let chain = Promise.resolve();
  let held = false;
  return {
    get held() { return held; },
    acquire() {
      let release;
      const released = new Promise((resolve) => {
        release = () => { held = false; resolve(); };
      });
      const mine = chain.then(() => { held = true; return release; });
      chain = chain.then(() => released);      // the next acquirer waits for this release
      return mine;
    },
    async runExclusive(fn) {
      const release = await this.acquire();
      try { return await fn(); } finally { release(); }
    },
  };
}

let balance = 100000;
const withdrawUnsafe = async (naira) => {
  const current = balance;          // read
  await Promise.resolve();          // ...anything at all happens here
  balance = current - naira;        // write, from a stale read
};
await Promise.all([withdrawUnsafe(30000), withdrawUnsafe(50000)]);
const lost = balance;

balance = 100000;
const mutex = createMutex();
const withdrawSafe = (naira) =>
  mutex.runExclusive(async () => {
    const current = balance;
    await Promise.resolve();
    balance = current - naira;
  });
await Promise.all([withdrawSafe(30000), withdrawSafe(50000)]);
const safe = balance;

const order = [];
const m2 = createMutex();
const first = m2.runExclusive(async () => {
  order.push('a in');
  await Promise.resolve();
  await Promise.resolve();
  order.push('a out');
});
const second = m2.runExclusive(async () => { order.push('b in'); order.push('b out'); });
await Promise.all([first, second]);

const m3 = createMutex();
const blewUp = await m3.runExclusive(async () => { throw new Error('inside the lock'); }).catch((e) => e.message);
const stillFree = m3.held;
const nextHolder = await m3.runExclusive(async () => 'not deadlocked');

console.log('without a lock:', lost, 'instead of', 100000 - 30000 - 50000);
console.log('with a lock   :', safe);
console.log('holder order  :', order);
console.log('after a throw :', blewUp, '- held:', stillFree);

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('without a lock, one update silently overwrites the other', lost, 50000);
test('which is 30,000 naira short of the truth', 100000 - 30000 - 50000, 20000);
test('with the mutex, both withdrawals land', safe, 20000);
test('and the lock is free again afterwards', mutex.held, false);
test('holders run to completion in the order they asked', order, ['a in', 'a out', 'b in', 'b out']);
test('a throw inside the critical section still releases', [blewUp, stillFree], ['inside the lock', false]);
test('so the next holder is not deadlocked', nextHolder, 'not deadlocked');
test('acquire hands you a release function', typeof (await createMutex().acquire()), 'function');

"JavaScript is single-threaded so there are no race conditions" is half a sentence. There are no data races — no torn writes, no half-updated objects — but there are plenty of logical races, and read-await-write is the canonical one. The first assertion is a lost update in six lines: both withdrawals read 100,000, and the second write wins.

The mutex is a chain, not a flag. chain always represents "the current holder has released", so acquire can hand back a promise that resolves in turn, and ordering falls out for free — the fifth assertion shows a running to completion before b starts, even though b had less to do.

finally is not optional. A throw inside the critical section with no finally leaves held true and chain waiting on a promise nobody will ever resolve, and every later acquire hangs for ever with no error to point at. The sixth and seventh assertions are the test you write once and then trust.

Two failure modes remain, and no mutex can fix them. It is not re-entrant: calling runExclusive from inside runExclusive deadlocks against itself. And two mutexes acquired in opposite orders by two callers deadlock in the classic way. Acquire in a fixed global order, or use one lock.

Answers JS Fundamental II 28 in the original set.

29 A semaphore with n permits

hard

Write a semaphore: acquire() resolves when a permit is free, release() gives one back. Run five jobs through one with two permits and prove the peak concurrency was two. Check what release does when someone is already waiting.

Stuck? Three hints, smallest first
  1. 1. A count of free permits and a queue of parked resolve functions is the whole state.
  2. 2. release() should hand the permit directly to the next waiter rather than returning it to the pool.
  3. 3. A use(fn) wrapper with try/finally is what callers should actually touch.

The answer

function createSemaphore(permits) {
  let free = permits;
  const waiting = [];
  return {
    get free() { return free; },
    get queued() { return waiting.length; },
    acquire() {
      if (free > 0) { free--; return Promise.resolve(); }
      return new Promise((resolve) => waiting.push(resolve));
    },
    release() {
      const next = waiting.shift();
      if (next) next();               // handed straight on: the count never moves
      else free++;
    },
    async use(fn) {
      await this.acquire();
      try { return await fn(); } finally { this.release(); }
    },
  };
}

const sem = createSemaphore(2);
let active = 0;
let peak = 0;
const job = (n) =>
  sem.use(async () => {
    active++;
    peak = Math.max(peak, active);
    await Promise.resolve();
    await Promise.resolve();
    active--;
    return n;
  });
const out = await Promise.all([1, 2, 3, 4, 5].map(job));

const one = createSemaphore(1);
let mutexPeak = 0;
let mutexActive = 0;
await Promise.all([1, 2, 3].map(() =>
  one.use(async () => {
    mutexActive++;
    mutexPeak = Math.max(mutexPeak, mutexActive);
    await Promise.resolve();
    mutexActive--;
  })
));

const queueing = createSemaphore(1);
queueing.acquire();
queueing.acquire();
const queuedState = [queueing.free, queueing.queued];

const handoff = createSemaphore(1);
await handoff.acquire();
const waiter = handoff.acquire();
handoff.release();
const duringHandoff = handoff.free;
await waiter;

const afterThrow = await (async () => {
  const s = createSemaphore(1);
  await s.use(async () => { throw new Error('x'); }).catch(() => {});
  return s.free;
})();

console.log('results:', out, ' peak:', peak, 'of 2');
console.log('permits of 1 behaves as a mutex, peak:', mutexPeak);
console.log('one permit, two acquirers:', queuedState);

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 job ran', out, [1, 2, 3, 4, 5]);
test('never more than two at once', peak, 2);
test('all permits are back afterwards', sem.free, 2);
test('and nobody is left queued', sem.queued, 0);
test('a semaphore of one permit is a mutex', mutexPeak, 1);
test('acquiring beyond the permits queues rather than failing', queuedState, [0, 1]);
test('release hands the permit straight to the waiter, so the count stays at zero', [duringHandoff, handoff.free], [0, 0]);
test('a throw inside use() still returns the permit', afterThrow, 1);

A semaphore generalises the mutex: n permits instead of one. The state is a count and a queue, and the only real design decision is in release. Handing the permit straight to the next waiter, rather than doing free++ and letting them race for it, is what makes the seventh assertion hold — free stays at zero throughout, because the permit was never free. It also makes the queue fair: first parked, first served.

Compare this with exercise 21's mapLimit. mapLimit owns the work, so it can start exactly limit runners and never needs a queue at all. A semaphore does not own the work, so callers arrive whenever they like and it needs somewhere to park them. Use mapLimit for a known list; use a semaphore to bound something that is called from all over — a database pool, an image resizer, an outbound API with a rate limit.

The failure modes are the mutex's. Forget the release and you leak a permit; leak all of them and every future acquire hangs silently. use(fn) with a finally exists so that callers never touch acquire and release directly, and the eighth assertion is the reason it is worth the extra method.

Answers JS Fundamental II 29 in the original set.

30 What then does with what you return

fair

Follow a value through a chain where one handler returns a plain value, one returns a promise, and one returns a thenable. Then check three identities: does then return a new promise, does Promise.resolve on a promise, and what does a non-function handler do?

Stuck? Three hints, smallest first
  1. 1. A returned value is wrapped. A returned promise is adopted, so the chain waits for it.
  2. 2. A thenable — any object with a then method — is adopted the same way.
  3. 3. then(null) is not an error. A non-callable handler is ignored and the value passes through.

The answer

const chained = await Promise.resolve(1)
  .then((n) => n + 1)                                       // a value is wrapped
  .then((n) => Promise.resolve(n * 10))                      // a promise is adopted
  .then((n) => ({ then: (resolve) => resolve(n + 5) }));     // a thenable, adopted too

const sameObject = (() => { const p = Promise.resolve(1); return p.then((x) => x) === p; })();
const resolveIsIdentity = (() => { const p = Promise.resolve(1); return Promise.resolve(p) === p; })();
const nested = await Promise.resolve(Promise.resolve('deep'));
const nothingReturned = String(await Promise.resolve(1).then(() => {}));
const ignoredHandler = await Promise.resolve('kept').then(null).then((v) => v);

const rounds = [];
Promise.resolve().then(() => rounds.push(1)).then(() => rounds.push(4)).then(() => rounds.push(6));
Promise.resolve().then(() => rounds.push(2)).then(() => rounds.push(5)).then(() => rounds.push(7));
Promise.resolve().then(() => rounds.push(3));
for (let i = 0; i < 8; i++) await Promise.resolve();

console.log('1 -> +1 -> x10 -> +5 =', chained);
console.log('then returns a new promise?', !sameObject, ' Promise.resolve is identity?', resolveIsIdentity);
console.log('returning nothing gives:', nothingReturned);
console.log('interleaving:', rounds);

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 returned value is wrapped and flows on', chained, 25);
test('then always returns a NEW promise', sameObject, false);
test('but Promise.resolve on a promise returns that same promise', resolveIsIdentity, true);
test('a nested promise is flattened, not wrapped twice', nested, 'deep');
test('a thenable is adopted like a promise', await Promise.resolve({ then: (resolve) => resolve('thenable') }), 'thenable');
test('returning nothing gives undefined, not the previous value', nothingReturned, 'undefined');
test('a non-function handler is ignored and the value passes through', ignoredHandler, 'kept');
test('parallel chains advance one link per microtask round', rounds, [1, 2, 3, 4, 5, 6, 7]);

One rule explains most of this: whatever a handler returns is passed to Promise.resolve. A number comes back wrapped, a promise comes back as itself, and anything with a callable then is treated as a promise and waited for. That is why there is no such thing as a Promise<Promise<T>> — the fourth assertion is that impossibility in one line.

Thenable adoption is the useful and dangerous half. Useful, because a promise from a different library or an older polyfill interoperates with no adapter. Dangerous, because any object you resolve with is duck-typed: give a plain data object a property called then that happens to be a function and the promise will call it and wait, rather than handing your object over.

The last assertion is worth internalising for reading logs. Independent chains advance one link per microtask round, so three chains interleave 1 2 3, then 4 5, then 6 7. Output that looks shuffled usually is not — it is breadth-first through the chains.

Answers JS Fundamental II 30 in the original set.

31 What finally on a promise can and cannot change

fair

Work out exactly what .finally() is allowed to do to a chain. Show that the value passes through it untouched, that its own return value is discarded, that a throw inside it replaces the outcome — and that a promise returned from it still delays everybody.

Stuck? Three hints, smallest first
  1. 1. finally is handed no arguments at all, so it cannot even see whether it is on the success or the failure path.
  2. 2. Returning a value from it changes nothing. Throwing from it changes everything.
  3. 3. To catch it delaying the chain, return a promise you control and check that nobody downstream has run yet.

The answer

const order = [];
let release;
const slowCleanup = new Promise((resolve) => { release = resolve; });

let sawArguments = -1;
const passedThrough = await Promise.resolve('₦2,500 paid').finally((...args) => {
  sawArguments = args.length;                  // nothing is passed in
  return 'this return value is thrown away';   // and nothing comes out
});

let cleanups = 0;
const rejectionSurvives = await Promise.reject(new Error('till 2 offline'))
  .finally(() => { cleanups++; })
  .catch((err) => err.message);
await Promise.resolve('ok').finally(() => { cleanups++; });

const throwWins = await Promise.resolve('would have been kept')
  .finally(() => { throw new Error('cleanup itself failed'); })
  .catch((err) => err.message);

const originalBuried = await Promise.reject(new Error('the real problem'))
  .finally(() => { throw new Error('cleanup itself failed'); })
  .catch((err) => err.message);

// A promise returned from finally IS waited for, even though its value is dropped.
const delayed = Promise.resolve('receipt LAG-0031')
  .finally(() => slowCleanup.then(() => order.push('cleanup finished')));
delayed.then((value) => order.push('caller got ' + value));
for (let i = 0; i < 6; i++) await Promise.resolve();
const beforeRelease = order.slice();
release();
await delayed;
for (let i = 0; i < 4; i++) await Promise.resolve();

console.log('through   :', passedThrough, '- arguments seen:', sawArguments);
console.log('rejection :', rejectionSurvives, '- cleanups run:', cleanups);
console.log('a throw   :', throwWins, '/', originalBuried);
console.log('order     :', beforeRelease, '->', order);

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 passes straight through finally', passedThrough, '₦2,500 paid');
test('which is called with no arguments at all', sawArguments, 0);
test('it runs on the failure path too, and the rejection carries on', rejectionSurvives, 'till 2 offline');
test('so both paths ran their cleanup', cleanups, 2);
test('a throw inside finally replaces a perfectly good value', throwWins, 'cleanup itself failed');
test('and buries the original reason, which is now unreachable', originalBuried, 'cleanup itself failed');
test('a promise returned from finally holds the chain up', beforeRelease, []);
test('so the caller is served only once the cleanup has finished', order, ['cleanup finished', 'caller got receipt LAG-0031']);

finally is deliberately blind. It gets no arguments, so it cannot tell success from failure, and its return value is discarded, so it cannot decide the outcome. That is the whole design: it is for releasing things, not for handling them. If you find yourself wanting to know which way it went, you wanted then(onOk, onErr).

The two assertions about throwing are the trap. A finally that fails does not merely fail — it overwrites whatever was there. The sixth is the bad one: a real failure came in, the cleanup threw on the way out, and the original reason is now gone from the chain entirely, with no cause linking them. Cleanup code that can throw belongs in its own try, or wrapped so it reports rather than replaces.

The last pair is the part people do not expect. Returning a value from finally is ignored, but returning a promise is not: the chain waits for it and only then passes the original value on. Useful when the cleanup must complete first — closing a transaction before the caller reads a total. Expensive when it was an accident, because an async cleanup function returns a promise whether you meant it to or not, and every caller now waits for it.

Answers JS Fundamental II 31 in the original set.

32 finally overriding a return

hard

Show what finally does to the value a function was already returning. Cover a return inside finally winning, the same thing swallowing a throw, the difference between reassigning the returned variable and mutating the returned object, and where continue ends up.

Stuck? Three hints, smallest first
  1. 1. The return value is captured when return runs, before finally does.
  2. 2. Captured means the reference is captured. Mutating what it points at is still visible to the caller.
  3. 3. A return in finally replaces the pending completion, whether that completion was a value or a throw.

The answer

const receipt = () => {
  try { return 'receipt LAG-0011'; }
  finally { /* release the printer, change nothing */ }
};

const overridden = () => {
  try { return 'receipt LAG-0011'; }
  finally { return 'whatever finally says'; }    // wins, silently
};

const swallowed = () => {
  try { throw new Error('printer offline'); }
  finally { return 'looks like a success'; }     // the throw is gone
};

const byValue = () => {
  let out = 'first';
  try { return out; }
  finally { out = 'changed after the return'; }  // too late: the value was taken
};

const byReference = () => {
  const out = { total: 1 };
  try { return out; }
  finally { out.total = 2; }                     // same object, so the caller sees it
};

const order = [];
const timing = () => {
  try { order.push('return computed'); return 'value'; }
  finally { order.push('finally ran'); }
};
timing();
order.push('caller resumed');

const asyncOverride = async () => {
  try { return await Promise.reject(new Error('gateway down')); }
  finally { return 'finally wins here too'; }
};
const rejectionLost = await asyncOverride();

const tills = [];
for (const till of ['ikeja', 'yaba', 'lekki']) {
  try {
    if (till === 'yaba') continue;
    tills.push(till);
  } finally {
    tills.push('closed ' + till);
  }
}

console.log('plain / overridden :', receipt(), '/', overridden());
console.log('a throw swallowed  :', swallowed());
console.log('value vs reference :', byValue(), '/', byReference());
console.log('continue           :', tills);

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 ordinary finally leaves the return value alone', receipt(), 'receipt LAG-0011');
test('but a return inside finally overrides it', overridden(), 'whatever finally says');
test('and swallows a throw completely, leaving no trace of it', swallowed(), 'looks like a success');
test('the returned value is captured before finally runs', byValue(), 'first');
test('though a mutation through the same reference still lands', byReference(), { total: 2 });
test('finally runs before the caller resumes', order, ['return computed', 'finally ran', 'caller resumed']);
test('the override applies to a rejected await as well', rejectionLost, 'finally wins here too');
test('continue runs the finally on its way out too', tills, ['ikeja', 'closed ikeja', 'closed yaba', 'lekki', 'closed lekki']);

A return in a finally block is almost always a bug, and the third assertion is why: an exception was travelling up, finally returned, and the exception simply ceased to exist. No log, no rethrow, a cheerful string handed back to a caller who now believes the receipt printed. Linters flag no-unsafe-finally for exactly this. The same goes for break, continue and throw in there.

The fourth and fifth assertions are one rule seen from two sides. return out evaluates out and keeps the result, so a later reassignment of the variable cannot reach it — but with an object, the result kept is the reference, and mutating the object afterwards is fully visible to the caller. Cleanup that resets a shared buffer you just returned is how you ship a race that only shows up under load.

The last one is the benign half of the same machinery: finally runs on every way out of the block, including continue, break and a throw, which is precisely what makes it the right place for a release. Note the ordering it produces — closed yaba appears with nothing pushed before it, because the continue skipped the body and still ran the cleanup.

Answers JS Fundamental II 32 in the original set.

33 A cancellation token, honoured co-operatively

hard

Build the token exercise 23 said was missing: cancel(reason), a cancelled flag, throwIfCancelled() and onCancel(fn). Have a multi-step job check it between steps. Then show the limit of the whole idea — a step already in flight carries on regardless.

Stuck? Three hints, smallest first
  1. 1. A token is state plus listeners. It has no power over anything: the work has to ask.
  2. 2. Cancelling twice must not fire the listeners twice, and the first reason is the one that counts.
  3. 3. Register a listener after the cancel and it should fire straight away, or a late subscriber hangs for ever.

The answer

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

function cancellation() {
  let cancelled = false;
  let reason = '';
  const listeners = [];
  return {
    get cancelled() { return cancelled; },
    get reason() { return reason; },
    throwIfCancelled() { if (cancelled) throw new CancelledError(reason); },
    onCancel(fn) { if (cancelled) fn(reason); else listeners.push(fn); },
    cancel(why) {
      if (cancelled) return false;                       // the first cancel wins
      cancelled = true;
      reason = why;
      for (const fn of listeners.splice(0)) fn(why);
      return true;
    },
  };
}

// The work co-operates: it checks the token before each unit of work.
async function payOut(tills, token, onStep) {
  const paid = [];
  for (const till of tills) {
    token.throwIfCancelled();
    await Promise.resolve();                             // the transfer
    paid.push(till);
    if (onStep) onStep(till);
  }
  return paid;
}

const token = cancellation();
const notified = [];
token.onCancel((why) => notified.push(why));

const seen = [];
const outcome = await payOut(['ikeja', 'yaba', 'lekki', 'ajah'], token, (till) => {
  seen.push(till);
  if (till === 'yaba') token.cancel('user closed the tab');
}).catch((err) => err);

const secondCancel = token.cancel('a different reason');
let lateSubscriber = 'never told';
token.onCancel((why) => { lateSubscriber = why; });

const dead = cancellation();
dead.cancel('cancelled before it started');
const never = [];
const nothingRan = await payOut(['ikeja'], dead, (till) => never.push(till)).catch((err) => err.name);

// The honest limit: cancelling mid-step does not interrupt that step.
const late = cancellation();
const finished = [];
const inFlight = (async () => {
  late.throwIfCancelled();
  await Promise.resolve();
  finished.push('the step that was already running');
  late.throwIfCancelled();
  finished.push('a step that never starts');
})().catch((err) => err.name);
late.cancel('too late for the step in flight');
const interrupted = await inFlight;

console.log('paid before the cancel :', seen);
console.log('outcome                :', outcome.name + ': ' + outcome.message);
console.log('already cancelled      :', nothingRan, '- steps run:', never.length);
console.log('mid-step cancel        :', interrupted, finished);

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 work stops at the first check after the cancel', seen, ['ikeja', 'yaba']);
test('and the caller gets a CancelledError carrying the reason', [outcome.name, outcome.message], ['CancelledError', 'user closed the tab']);
test('which is a real Error subclass, so instanceof holds', [outcome instanceof CancelledError, outcome instanceof Error], [true, true]);
test('a token cancelled up front stops the job before step one', [nothingRan, never], ['CancelledError', []]);
test('listeners fire exactly once, with the reason', notified, ['user closed the tab']);
test('a listener registered afterwards fires immediately instead of hanging', lateSubscriber, 'user closed the tab');
test('the second cancel is ignored and the first reason stands', [secondCancel, token.reason], [false, 'user closed the tab']);
test('but a step already in flight runs to the end: only the next check stops', [finished, interrupted], [['the step that was already running'], 'CancelledError']);

A token has no authority. It is a flag, a reason and a list of listeners, and nothing happens unless the work asks — which is why throwIfCancelled() sits at the top of the loop body. This is exactly how the platform's own AbortController works: signal.aborted, signal.reason, addEventListener(‘abort’). fetch honours it because fetch was written to; a function that ignores the signal is unaffected by it.

The last assertion is the boundary worth internalising, and it is the answer to the leak in exercise 23. Cancellation is granular to the checks you wrote. Between two checks the work is uninterruptible, so a long single step — one enormous query, one large upload — cannot be cancelled at all, no matter how many tokens you thread through. Cutting a job into steps is what makes it cancellable.

Two details that look like fussiness and are not. cancel returning false the second time keeps the reason stable, so the error message does not depend on who cancelled last. And onCancel firing immediately when the token is already cancelled removes an entire class of race: a subscriber that arrives one microtask late would otherwise wait for an event that has already happened.

Answers JS Fundamental II 33 in the original set.

34 Error subclasses that survive instanceof

fair

Build a two-level error hierarchy and route a handler by instanceof. Then find the trap in the version that forgets this.name, and check which of an error's fields are actually enumerable.

Stuck? Three hints, smallest first
  1. 1. super(message, options) forwards both, so a subclass supports cause for free.
  2. 2. Error.prototype.toString reads this.name, so forgetting to set it makes every subclass introduce itself as Error.
  3. 3. Assigning this.name creates an own, enumerable property. message is never one.

The answer

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

class InsufficientFunds extends PaymentError {
  constructor(shortfall, options) {
    super(`short by ₦${shortfall}`, options);
    this.name = 'InsufficientFunds';
    this.shortfall = shortfall;
  }
}

class Bare extends Error {}                    // the trap: no name assigned

const err = new InsufficientFunds(1500);
const chain = [
  err instanceof InsufficientFunds,
  err instanceof PaymentError,
  err instanceof Error,
  err instanceof TypeError,
];

const handle = (e) => {
  if (e instanceof InsufficientFunds) return 'top up ₦' + e.shortfall;
  if (e instanceof PaymentError) return 'payment problem: ' + e.message;
  return 'not ours: ' + e.name;
};
const routed = [handle(err), handle(new PaymentError('card declined')), handle(new RangeError('out of range'))];

const bare = new Bare('printer offline');
const survivesRejection = await Promise.reject(err).catch((e) => e instanceof InsufficientFunds);
const wrapped = new PaymentError('could not take payment', { cause: bare });

console.log('description :', String(err));
console.log('routed      :', routed);
console.log('the trap    :', bare.name, '/', String(bare), '/ but constructor:', bare.constructor.name);
console.log('own keys    :', Object.keys(err));

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 whole prototype chain is intact', chain, [true, true, true, false]);
test('so instanceof is enough to route the handler', routed, ['top up ₦1500', 'payment problem: card declined', 'not ours: RangeError']);
test('name and message compose into the description', String(err), 'InsufficientFunds: short by ₦1500');
test('a subclass that forgets this.name introduces itself as Error', [bare.name, String(bare)], ['Error', 'Error: printer offline']);
test('though its constructor still knows what it is', bare.constructor.name, 'Bare');
test('assigning name makes it an own key; message never is one', Object.keys(err), ['name', 'shortfall']);
test('instanceof survives a trip through a rejected promise', survivesRejection, true);
test('and the subclass forwards cause to Error for free', wrapped.cause.message, 'printer offline');

Subclassing Error works properly in ES6 classes, and the first two assertions are the payoff: instanceof gives you dispatch, so a handler branches on the kind of failure instead of matching on message text. Message matching is the thing this replaces — messages get reworded, translated and interpolated, and every if (e.message.includes(‘declined’)) is a bug waiting for a copy edit.

this.name is the part everyone forgets. It is not decorative: toString, and therefore every default log line and every console.error, reads it. A hierarchy of five error classes that all announce themselves as Error is a hierarchy you cannot read in production, even though instanceof still works perfectly in code. this.constructor.name is the fallback that needs no assignment, and it is also what breaks first under a minifier.

The enumerability assertion is a preview of exercise 42. name is an own key because you assigned it; shortfall likewise; message and stack are inherited or non-enumerable, so they vanish from Object.keys, from a spread and from JSON.stringify. That single fact explains most "the error logged as {}" reports.

One older trap worth knowing: transpiled down to ES5, extends Error loses the prototype and instanceof silently returns false, which is why old code carries Object.setPrototypeOf(this, new.target.prototype). Native classes need none of that.

Answers JS Fundamental II 34 in the original set.

35 Walking a cause chain to the root

fair

Wrap a low-level failure twice, then write chainOf(err) and rootCause(err) that walk cause to the bottom. Make the walk survive a cycle, and check what cause does to serialisation.

Stuck? Three hints, smallest first
  1. 1. Each layer adds the context it has: the socket knows the reset, the service knows the order number.
  2. 2. Walk with while (current instanceof Error) — the chain ends when cause is missing or is not an error.
  3. 3. Keep a Set of errors already visited, or a chain that loops back will spin for ever.

The answer

const socket = new Error('ECONNRESET from the card network');
const driver = new Error('charge request failed', { cause: socket });
const service = new Error('could not take payment for order LAG-0035', { cause: driver });

function chainOf(err) {
  const messages = [];
  const seen = new Set();
  let current = err;
  while (current instanceof Error && !seen.has(current)) {
    seen.add(current);
    messages.push(current.message);
    current = current.cause;
  }
  return messages;
}

function rootCause(err) {
  const seen = new Set();
  let current = err;
  while (current.cause instanceof Error && !seen.has(current.cause)) {
    seen.add(current);
    current = current.cause;
  }
  return current;
}

const loopA = new Error('a');
const loopB = new Error('b', { cause: loopA });
loopA.cause = loopB;                                  // a cycle, by accident

const report = chainOf(service).map((m, depth) => '  '.repeat(depth) + m).join('\n');
console.log('the chain, outside in:');
console.log(report);
console.log('root cause :', rootCause(service).message);
console.log('serialised :', JSON.stringify(service), Object.keys(service));

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 chain reads outside in', chainOf(service), ['could not take payment for order LAG-0035', 'charge request failed', 'ECONNRESET from the card network']);
test('and the root cause is the one worth alerting on', rootCause(service).message, 'ECONNRESET from the card network');
test('a plain error is a chain of one, and its own root', [chainOf(socket).length, rootCause(socket) === socket], [1, true]);
test('cause exists only when you pass it', ['cause' in service, 'cause' in socket], [true, false]);
test('but passing cause: undefined installs the property anyway', 'cause' in new Error('x', { cause: undefined }), true);
test('cause is not enumerable, so the chain does not serialise', [Object.keys(service), JSON.stringify(service)], [[], '{}']);
test('the walk terminates on a cycle instead of hanging', chainOf(loopB), ['b', 'a']);
test('a non-error cause ends the walk rather than breaking it', chainOf(new Error('top', { cause: 'just a string' })), ['top']);

Every layer knows something the others do not. The socket knows the reset; the driver knows which request; the service knows which order and which customer is waiting. cause is what lets each one add its context without deleting what came before, and rootCause is what an alert should fire on — the outermost message is the most human but the least diagnostic.

The cycle guard is not paranoia. Two errors that reference each other is an easy accident in retry code that attaches the previous failure as the cause of the next, and without the Set the walk is an infinite loop in your error handler, which is the worst possible place for one. The same guard is why chainOf checks instanceof Error rather than truthiness: cause can hold anything, including a string or a response object, and the walk should stop rather than throw.

The serialisation assertion is the practical sting. cause is defined non-enumerably by the spec, so a chain three deep is invisible to JSON.stringify, to a spread and to most log shippers. Node's console.error and util.inspect print it because they were taught to; your logger has to be taught too, which is what exercise 42 builds.

Answers JS Fundamental II 35 in the original set.

36 Collecting failures into an AggregateError

fair

Upload four batches, two of which fail, and report every failure in one error instead of the first. Use allSettled to gather them and AggregateError to carry them, then check what it does and does not expose.

Stuck? Three hints, smallest first
  1. 1. allSettled never rejects, so you decide what a partial failure means.
  2. 2. new AggregateError(reasons, message) — the reasons come first, the summary second.
  3. 3. It copies whatever iterable you hand it into a plain array, and that array is not enumerable.

The answer

const uploads = () => [
  Promise.resolve('ikeja receipts'),
  Promise.reject(new Error('yaba: timed out')),
  Promise.resolve('lekki receipts'),
  Promise.reject(new Error('ajah: 500 from the API')),
];

async function allOrReport(tasks) {
  const settled = await Promise.allSettled(tasks);
  const failures = settled.filter((r) => r.status === 'rejected').map((r) => r.reason);
  if (failures.length) {
    throw new AggregateError(failures, `${failures.length} of ${settled.length} uploads failed`);
  }
  return settled.map((r) => r.value);
}

const agg = await allOrReport(uploads()).catch((err) => err);
const clean = await allOrReport([Promise.resolve('one'), Promise.resolve('two')]);
const fromSet = new AggregateError(new Set([new Error('a'), new Error('b')]), 'from any iterable');
const nested = new Error('nightly batch failed', { cause: agg });
const builtForYou = await Promise.any([
  Promise.reject(new Error('mirror 1 down')),
  Promise.reject(new Error('mirror 2 down')),
]).catch((err) => [err.name, err.errors.length, err.message.length > 0]);

console.log('summary  :', agg.message);
console.log('reasons  :', agg.errors.map((e) => e.message));
console.log('clean run:', clean);
console.log('serialise:', JSON.stringify(agg), Object.keys(agg));

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 AggregateError carries every reason, in input order', agg.errors.map((e) => e.message), ['yaba: timed out', 'ajah: 500 from the API']);
test('with one message summarising them', agg.message, '2 of 4 uploads failed');
test('it is an Error, so an ordinary catch still holds it', [agg instanceof AggregateError, agg instanceof Error], [true, true]);
test('nothing is thrown when nothing failed', clean, ['one', 'two']);
test('any iterable of reasons is copied into a plain array', [Array.isArray(fromSet.errors), fromSet.errors.length], [true, 2]);
test('errors is non-enumerable too, so it does not serialise', [Object.keys(agg), JSON.stringify(agg)], [[], '{}']);
test('an aggregate nests inside a cause like any other error', nested.cause.errors.length, 2);
test('Promise.any builds one for you when every input fails', builtForYou, ['AggregateError', 2, true]);

This is the shape to reach for whenever failures are independent. Promise.all answers "did anything fail" and throws away the rest; allSettled plus AggregateError answers "what exactly failed", which is the only useful thing to put in a nightly job's alert. Two of four uploads failing is one incident with two lines, not one line and a shrug.

errors is in input order, not completion order, which is what makes it worth correlating with your input list — index 1 and index 3 of the batch, every time you run it. Note also that the summary message is yours to write. Promise.any's automatic one is engine-specific wording, which is why the assertion only checks that it is non-empty rather than what it says.

The serialisation line repeats exercise 35's lesson because it bites twice: errors follows the same non-enumerable rule as cause, so an aggregate posted to a log endpoint as JSON arrives as {} and you learn nothing. Flatten it yourself — { message, errors: agg.errors.map(toPlain) } — which is exactly the projection exercise 42 writes.

Answers JS Fundamental II 36 in the original set.

37 Rethrowing without throwing the evidence away

hard

Pass one failure up three ways: rethrow it, rebuild it from its message, and wrap it with cause. Show what each keeps and what each destroys. Then write the handler that deals with the error it recognises and rethrows everything else untouched.

Stuck? Three hints, smallest first
  1. 1. throw err hands the very same object up. Identity and stack are unchanged.
  2. 2. new Error(err.message) is a new object: new stack, no class, no cause.
  3. 3. A handler that catches everything and reports it as its own problem is how a bug becomes unfindable.

The answer

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

const failures = [];
const driver = () => {
  const err = new RepositoryError('duplicate key on tills.name');
  failures.push(err);                                   // so the test can compare identity
  throw err;
};

const rethrown = () => { try { driver(); } catch (err) { throw err; } };
const rebuilt = () => { try { driver(); } catch (err) { throw new Error(err.message); } };
const wrapped = () => { try { driver(); } catch (err) { throw new Error('could not save the till', { cause: err }); } };

const bare = (() => {
  try { rethrown(); } catch (err) { return { sameObject: err === failures.at(-1), name: err.name }; }
})();

const flat = (() => {
  try { rebuilt(); } catch (err) {
    return {
      isRepositoryError: err instanceof RepositoryError,
      causeMissing: err.cause === undefined,
      sameStack: err.stack === failures.at(-1).stack,
    };
  }
})();

const kept = (() => {
  try { wrapped(); } catch (err) {
    return {
      message: err.message,
      causeIsOriginal: err.cause === failures.at(-1),
      causeStillTyped: err.cause instanceof RepositoryError,
    };
  }
})();

// Handle what you understand; rethrow the rest exactly as it came.
const handleOnly = (fn) => {
  try { return fn(); }
  catch (err) {
    if (err instanceof RepositoryError) return 'handled: ' + err.message;
    throw err;
  }
};
const mine = handleOnly(driver);
const notMine = (() => {
  try { handleOnly(() => { throw new TypeError('cannot read property of undefined'); }); }
  catch (err) { return err.name; }
})();

const target = new RepositoryError('carried across an await');
const acrossAwait = await (async () => {
  try { await Promise.reject(target); } catch (err) { throw err; }
})().then(() => false, (err) => err === target);

console.log('rethrown :', bare);
console.log('rebuilt  :', flat);
console.log('wrapped  :', kept);
console.log('selective:', mine, '/ passed through:', notMine);

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('throw err passes the very same object up', [bare.sameObject, bare.name], [true, 'RepositoryError']);
test('rebuilding from the message loses the class', flat.isRepositoryError, false);
test('and loses the original, which is now unreachable', flat.causeMissing, true);
test('and carries a different stack: the rethrow site, not the failure site', flat.sameStack, false);
test('wrapping with cause keeps the original reachable and still typed', [kept.causeIsOriginal, kept.causeStillTyped], [true, true]);
test('while adding the context only this layer knew', kept.message, 'could not save the till');
test('a handler that knows one error rethrows the rest untouched', [mine, notMine], ['handled: duplicate key on tills.name', 'TypeError']);
test('identity survives a rejection and a catch across an await', acrossAwait, true);

Three ways up, and only two of them are honest. throw err is free and lossless: same object, same stack, same class, so the top of the application sees the failure exactly as it happened. Wrapping with cause costs one line and adds the context the lower layer could not know — which order, which customer — while keeping the original one hop away. new Error(err.message) keeps the sentence and destroys everything that made it debuggable.

The fourth assertion is the specific loss people underestimate. A stack is captured when the error is constructed, so a rebuilt error's stack points at your catch block, not at the line that actually failed. You end up with a trace whose deepest frame is your own error handling. Rethrowing the same object, or wrapping it, keeps the real trace intact underneath.

The selective handler is the shape to copy. catch (err) { log(err); return null; } around a whole operation is how a TypeError from a genuine bug gets reported as "could not save the till" and stays in the codebase for a year. Catch narrowly — by class, in one place — and let anything you did not anticipate keep travelling.

Answers JS Fundamental II 37 in the original set.

The exercise list follows w3resource's JavaScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.

Newsletter

New writing, when it lands.

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