Emmanuel Akinfulubi

JavaScript: Fundamental (ES6), part 1, 107–136

The modern-syntax set: small, sharp utility functions.

Exercises 107 to 136 of 150

107 a Result type, so failure is a value

hard

Write a Result class with static ok and err constructors, map, mapErr, andThen, unwrapOr and match. Chain a validation so the first failure short-circuits the rest, and give it a toJSON so the assertions can see inside a class with private fields.

Stuck? Three hints, smallest first
  1. 1. Keep the state in private fields and never expose them. The only ways out should be unwrapOr and match, which force the caller to consider failure.
  2. 2. map takes a plain value to a plain value; andThen takes a value to another Result. Using map where you needed andThen gives you a Result inside a Result.
  3. 3. On the failure path, both map and andThen can return this unchanged. That is what makes the chain short-circuit.

The answer

class Result {
  #ok;
  #value;
  #error;

  constructor(ok, value, error) {
    this.#ok = ok;
    this.#value = value;
    this.#error = error;
  }

  static ok(value) { return new Result(true, value, undefined); }
  static err(error) { return new Result(false, undefined, error); }

  get isOk() { return this.#ok; }

  map(fn) { return this.#ok ? Result.ok(fn(this.#value)) : this; }
  mapErr(fn) { return this.#ok ? this : Result.err(fn(this.#error)); }
  andThen(fn) { return this.#ok ? fn(this.#value) : this; }
  unwrapOr(fallback) { return this.#ok ? this.#value : fallback; }
  match(handlers) { return this.#ok ? handlers.ok(this.#value) : handlers.err(this.#error); }

  toJSON() { return this.#ok ? { ok: true, value: this.#value } : { ok: false, error: this.#error }; }
}

const parseAmount = (text) => {
  const n = Number(text);
  if (text.trim() === '' || Number.isNaN(n)) return Result.err(`not a number: ${text}`);
  if (n <= 0) return Result.err('amount must be positive');
  return Result.ok(n);
};
const withinLimit = (n) => (n <= 50000 ? Result.ok(n) : Result.err('above the single-transfer limit'));

let conversions = 0;
const toKobo = (n) => {
  conversions++;
  return n * 100;
};

const good = parseAmount('4500').andThen(withinLimit).map(toKobo);
const tooBig = parseAmount('90000').andThen(withinLimit).map(toKobo);
const notANumber = parseAmount('abeg').andThen(withinLimit).map(toKobo);
const negative = parseAmount('-5');
const conversionsRun = conversions;

const failed = parseAmount('abeg');
const mapWasSkipped = failed.map(toKobo) === failed;
const shouted = failed.mapErr((e) => e.toUpperCase());
const untouched = good.mapErr((e) => e.toUpperCase()) === good;

const fallbacks = [good.unwrapOr(0), notANumber.unwrapOr(0)];
const messages = [good, tooBig].map((r) => r.match({ ok: (n) => `transfer ${n} kobo`, err: (e) => `declined: ${e}` }));

class Bare {
  #hidden = 'nothing to see';
}
const bareShape = JSON.stringify(new Bare());

console.log('good    :', JSON.stringify(good), '| too big:', JSON.stringify(tooBig));
console.log('skipped :', conversionsRun, 'conversions for four chains');
console.log('messages:', messages);
console.log('bare    :', bareShape);

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('an ok carries its value, and toJSON is what makes it comparable', good, { ok: true, value: 450000 });
test('a failure carries a reason instead, and the rules are checked in the order written', [notANumber, negative], [{ ok: false, error: 'not a number: abeg' }, { ok: false, error: 'amount must be positive' }]);
test('andThen chains a function that returns a Result, without nesting one inside another', tooBig, { ok: false, error: 'above the single-transfer limit' });
test('the first failure short-circuits, so the conversion ran once across four chains', conversionsRun, 1);
test('map on a failure returns the very same instance, untouched', [mapWasSkipped, failed.isOk], [true, false]);
test('mapErr is the mirror image: it rewrites the reason and leaves an ok alone', [shouted, untouched], [{ ok: false, error: 'NOT A NUMBER: ABEG' }, true]);
test('unwrapOr is the escape hatch that cannot forget the failure case', fallbacks, [450000, 0]);
test('match makes you write both branches, which is the whole point', messages, ['transfer 450000 kobo', 'declined: above the single-transfer limit']);
test('without toJSON a class with private fields stringifies to nothing at all', bareShape, '{}');

A Result turns failure into a value, which means the type system of your head — or an actual type system — can see it. A function returning Result announces that it can fail; a function that throws does not, and every caller has to know or find out the hard way. The trade is that Result only helps for expected failures. A bug should still throw: you do not want to write andThen chains for out-of-memory.

map versus andThen is the distinction worth getting right, and it is the same one as Array#map versus Array#flatMap. map lifts a plain function into the wrapper; andThen takes a function that returns its own wrapper and does not double it up. Use map where you needed andThen and you get a Result whose value is a Result, which type-checks nowhere and fails at the match.

Two implementation notes. Returning this on the failure path is what short-circuits the chain, and the assertion counting conversions is the proof: four chains, three of them failing, one call to toKobo. And the last assertion is about the harness — JSON.stringify cannot see private fields, so a class instance stringifies to {} and every assertion about it would trivially pass. Adding toJSON gives the class a deliberate public projection, which makes it both loggable and testable.

108 a memo keyed by object identity, in a WeakMap

fair

Memoise a function of one object argument in a WeakMap, so the cache entry disappears when the object does. Show that identity is the key rather than contents, that a cached 0 is still a hit, that mutating the object leaves the memo stale, and that a WeakMap offers nothing to enumerate.

Stuck? Three hints, smallest first
  1. 1. Use cache.has(key) to decide, not the truthiness of cache.get(key), or every cached 0, '' or false is recomputed for ever.
  2. 2. A WeakMap key must be an object. set with a string throws; get and has with one just answer politely.
  3. 3. There is no size, no keys and no forEach on a WeakMap. That absence is the feature, not an omission.

The answer

const memoByObject = (fn) => {
  const cache = new WeakMap();
  return (subject) => {
    if (cache.has(subject)) return cache.get(subject);
    const value = fn(subject);
    cache.set(subject, value);
    return value;
  };
};

let computations = 0;
const totalOf = (invoice) => {
  computations++;
  return invoice.lines.reduce((sum, line) => sum + line.units * line.price, 0);
};
const cachedTotal = memoByObject(totalOf);

const invoice = { ref: 'INV-9', lines: [{ units: 2, price: 4500 }, { units: 1, price: 1800 }] };
const twin = { ref: 'INV-9', lines: [{ units: 2, price: 4500 }, { units: 1, price: 1800 }] };

const first = cachedTotal(invoice);
const again = cachedTotal(invoice);
const callsAfterRepeat = computations;
const forTwin = cachedTotal(twin);
const callsAfterTwin = computations;

invoice.lines[0].units = 5;                 // the object changed; the cache did not notice
const stale = cachedTotal(invoice);
const truth = totalOf(invoice);
const callsAfterMutation = computations;

const secondMemo = memoByObject(totalOf);
const recomputed = secondMemo(invoice);
const callsAfterSecondMemo = computations;

let zeroCalls = 0;
const emptyTotal = memoByObject((inv) => {
  zeroCalls++;
  return inv.lines.length;
});
const empty = { ref: 'INV-0', lines: [] };
const zeros = [emptyTotal(empty), emptyTotal(empty), zeroCalls];

const weak = new WeakMap();
let keyError = 'no error';
try { weak.set('INV-9', 1); } catch (e) { keyError = e.constructor.name; }
const primitiveLookup = weak.has('INV-9');
const surface = [typeof weak.size, typeof weak.keys, typeof weak.forEach];

const strong = new Map();
strong.set({ ref: 'INV-1' }, 'held for ever');
const strongHolds = [...strong.keys()].length;

const secrets = new WeakMap();
class Wallet {
  constructor(pin) { secrets.set(this, { pin }); }
  matches(guess) { return secrets.get(this).pin === guess; }
}
const wallet = new Wallet('1234');
const walletShape = [JSON.stringify(wallet), Object.keys(wallet).length, wallet.matches('1234'), wallet.matches('0000')];

console.log('cached   :', first, again, '| calls:', callsAfterRepeat);
console.log('twin     :', forTwin, '| calls:', callsAfterTwin);
console.log('stale    :', stale, 'but the truth is', truth);
console.log('weakmap  :', keyError, primitiveLookup, surface);

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('the same object twice is one computation', [first, again, callsAfterRepeat], [10800, 10800, 1]);
test('an identical-looking twin is a different key, so it computes again', [forTwin, callsAfterTwin], [10800, 2]);
test('mutating the object does not invalidate anything: the memo is now wrong', [stale, truth, callsAfterMutation], [10800, 24300, 3]);
test('a second memo over the same function has its own cache', [recomputed, callsAfterSecondMemo], [24300, 4]);
test('a cached 0 is a hit, because has decided and not truthiness', zeros, [0, 0, 1]);
test('a WeakMap key must be an object: set throws, has just says no', [keyError, primitiveLookup], ['TypeError', false]);
test('and there is no size, no keys and no forEach to enumerate it with', surface, ['undefined', 'undefined', 'undefined']);
test('a Map used the same way keeps its keys reachable, and alive, for ever', strongHolds, 1);
test('a WeakMap keyed on this is how private state was done before # fields', walletShape, ['{}', 0, true, false]);

Keying on the argument itself means no serialisation, no key collisions and no key-order bugs — all the failures a JSON.stringify cache key has. The price is that identity is the only thing it can compare: two objects with the same contents are two entries, so this only pays off when callers hand you the same object repeatedly, which is exactly what happens with a React prop, a config object or a parsed document.

The WeakMap part is about lifetime. Entries hold their keys weakly, so an invoice that goes out of scope takes its cache entry with it and the memo cannot leak. The Map assertion is the contrast: a Map holds its keys strongly, and because you can list them, the collector can never conclude that nobody is looking. Being unable to enumerate a WeakMap is not a missing feature; it is the thing that makes weakness observable-free and therefore safe.

Staleness is the failure mode to remember. The cache is keyed on identity, not on contents, so mutating the object leaves the old answer in place and the memo confidently returns a number that is now wrong. Only memoise pure functions of values you treat as immutable — and if the data does change in place, the cache key has to change with it, which means a version counter or a fresh object.

109 a Proxy that validates every write

hard

Wrap an account object in a Proxy whose set trap checks each field against a rule, rejects an unknown field name outright, and refuses delete. Show that reads pass straight through, that the target is untouched by a rejected write, that writing to the target directly bypasses everything, and what a set trap returning false does under strict mode.

Stuck? Three hints, smallest first
  1. 1. The set trap receives (target, key, value). Throwing from it is the clearest way to reject a write; returning false is the other way.
  2. 2. Use Reflect.set(target, key, value) to perform the write you approved, rather than target[key] = value. Same effect, and it returns the boolean the trap is supposed to return.
  3. 3. A rule table keyed by field name gives you unknown-field detection for free: no rule, no write.

The answer

const guarded = (target, rules) =>
  new Proxy(target, {
    set(obj, key, value) {
      const rule = rules[key];
      if (!rule) throw new TypeError(`unknown field: ${String(key)}`);
      if (!rule.check(value)) throw new TypeError(`${String(key)}: ${rule.why}`);
      return Reflect.set(obj, key, value);
    },
    deleteProperty(obj, key) {
      throw new TypeError(`${String(key)} cannot be removed`);
    },
  });

const rules = {
  name: { check: (v) => typeof v === 'string' && v.length > 0, why: 'must be a non-empty string' },
  balanceKobo: { check: (v) => Number.isInteger(v) && v >= 0, why: 'must be a whole, non-negative number of kobo' },
  lga: { check: (v) => ['Yaba', 'Ikeja', 'Eti-Osa'].includes(v), why: 'must be a serviced LGA' },
};

const raw = { name: 'Ngozi Traders', balanceKobo: 450000, lga: 'Yaba' };
const account = guarded(raw, rules);

account.balanceKobo = 500000;
const accepted = [account.balanceKobo, raw.balanceKobo];

let rangeMessage = 'no error';
try { account.balanceKobo = -1; } catch (e) { rangeMessage = e.message; }
const afterReject = raw.balanceKobo;

let unknownMessage = 'no error';
try { account.blance = 1; } catch (e) { unknownMessage = e.message; }
const keyCountAfterTypo = Object.keys(raw).length;

let deleteMessage = 'no error';
try { delete account.lga; } catch (e) { deleteMessage = e.message; }

const readThrough = [account.name, account.lga, Object.keys(account)];

raw.balanceKobo = -999;                       // straight past the door
const bypassed = [account.balanceKobo, raw.balanceKobo];

const readOnly = new Proxy({ tier: 1 }, { set: () => false });
let refusal = 'no error';
try { readOnly.tier = 2; } catch (e) { refusal = e.constructor.name; }

console.log('accepted :', accepted);
console.log('rejected :', rangeMessage, '| target still:', afterReject);
console.log('unknown  :', unknownMessage, '| delete:', deleteMessage);
console.log('bypass   :', bypassed, '| false trap:', refusal);

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 write that satisfies its rule lands on the target', accepted, [500000, 500000]);
test('a write that breaks it throws, carrying the rule its own words', rangeMessage, 'balanceKobo: must be a whole, non-negative number of kobo');
test('and the target is exactly as it was, so nothing half-happened', afterReject, 500000);
test('a misspelled field is an error rather than a brand new property', [unknownMessage, keyCountAfterTypo], ['unknown field: blance', 3]);
test('delete is refused too, by a trap of its own', deleteMessage, 'lga cannot be removed');
test('reads pass straight through, because there is no get trap to change them', readThrough, ['Ngozi Traders', 'Yaba', ['name', 'balanceKobo', 'lga']]);
test('writing to the target directly bypasses every rule, so keep the target private', bypassed, [-999, -999]);
test('under strict mode a set trap returning false throws instead of failing quietly', refusal, 'TypeError');

A Proxy is a door, not a lock. Every guarantee here holds only for code that goes through the proxy: the last-but-one assertion writes -999 straight onto the target and the rules never run. So the target must be unreachable — created inside the factory, closed over, never returned — or the validation is decoration.

Rejecting an unknown field is the part that earns the proxy its cost. A plain object accepts account.blance = 1 silently and the bug surfaces later somewhere unrelated; here it throws at the line that caused it. The rule table doubles as the schema, so there is one place to read to know what a valid account looks like.

Two mechanics worth knowing. Reflect.set is the function form of assignment and returns the boolean the trap is required to return, which is why the trap body ends with it rather than with a bare assignment plus return true. And a set trap has two ways to say no: throw, or return false. Returning false fails silently in sloppy mode and throws a bare TypeError in strict mode, which every module and every one of these panels is — so it throws, but with a message that says nothing about your rule. Throwing yourself is almost always clearer.

110 Reflect, a defaulting getter, and the receiver that matters

fair

Write withDefaults(target, fallback): a Proxy whose get trap returns a default for any key the target does not own, where the fallback may be a value or a function of the key. Then prove why Reflect.get needs its third argument by putting a getter on the target that reads a key which only exists as a default.

Stuck? Three hints, smallest first
  1. 1. Object.hasOwn(target, key) decides whether to read or to invent. Reflect.get(target, key) does the reading.
  2. 2. Reflect.get(target, key, receiver) takes a third argument that becomes this inside any getter it runs. Leave it out and this is the bare target.
  3. 3. The get trap changes reads and nothing else, so in, Object.keys and JSON.stringify still report the truth. That inconsistency is the thing to be careful about.

The answer

const withDefaults = (target, fallback) =>
  new Proxy(target, {
    get(obj, key, receiver) {
      if (Object.hasOwn(obj, key)) return Reflect.get(obj, key, receiver);
      return typeof fallback === 'function' ? fallback(key) : fallback;
    },
  });

const salesByDay = { mon: 12000, tue: 9000 };
const sales = withDefaults(salesByDay, 0);
const named = withDefaults(salesByDay, (key) => `no sales for ${String(key)}`);

const present = sales.mon;
const missing = sales.sun;
const explained = named.sun;
const inDisagrees = ['sun' in sales, sales.sun];
const stillHonest = [Object.keys(sales), JSON.stringify(sales), { ...sales }];

const basket = {
  units: 3,
  price: 4500,
  get subtotal() { return this.units * this.price; },
  get total() { return this.subtotal + this.deliveryFee; },   // deliveryFee is not here
};
const tally = withDefaults(basket, 0);
const noReceiver = new Proxy(basket, {
  get(obj, key) {
    return Object.hasOwn(obj, key) ? Reflect.get(obj, key) : 0;   // receiver dropped
  },
});

const throughProxy = [tally.subtotal, tally.total];
const straightAtTheObject = Number.isNaN(basket.total);
const withoutReceiver = Number.isNaN(noReceiver.total);

const reflectBasics = [Reflect.get(salesByDay, 'mon'), Reflect.has(salesByDay, 'sun'), Reflect.ownKeys({ a: 1, b: 2 })];

console.log('defaults :', present, missing, '| named:', explained);
console.log('in says  :', inDisagrees, '| keys:', stillHonest[0]);
console.log('receiver :', throughProxy);
console.log('without  :', withoutReceiver, '| direct:', straightAtTheObject);

Output


      
9 checks that prove this answer

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

9 checks

test('a key the target owns reads normally', present, 12000);
test('a key it does not own comes back as the default', missing, 0);
test('a function fallback is handed the key, so the default can mention it', explained, 'no sales for sun');
test('in still tells the truth, so reads and membership now disagree', inDisagrees, [false, 0]);
test('keys, JSON and the spread all see only the real entries', stillHonest, [['mon', 'tue'], '{"mon":12000,"tue":9000}', { mon: 12000, tue: 9000 }]);
test('passing the receiver makes this inside a getter the proxy, so it sees defaults', throughProxy, [13500, 13500]);
test('the same getter read straight off the object adds undefined and gets NaN', straightAtTheObject, true);
test('and dropping the third argument to Reflect.get puts it back to NaN', withoutReceiver, true);
test('Reflect is the function form of the operators: get, has, ownKeys', reflectBasics, [12000, false, ['a', 'b']]);

The receiver is the subtle part, and the two NaN assertions are the reason to care. basket.total reads this.deliveryFee, which does not exist, so the arithmetic gives NaN. Read the same getter through the proxy with the receiver forwarded and this is the proxy, so this.deliveryFee goes back through the trap and picks up the default — the getter suddenly works. Drop the third argument and this is the plain target again and it breaks. Forwarding the receiver is not optional politeness; it is what makes a proxy compose with getters, and the same argument applies to Reflect.set.

The honesty assertions are the warning. A get trap changes reads only, so 'sun' in sales is false while sales.sun is 0, and Object.keys, the spread and JSON.stringify all report the target unchanged. Code that checks for a key before reading it therefore takes a different path from code that just reads, which is a genuinely confusing bug to be handed. Trap has and ownKeys as well if the illusion needs to be complete — and think hard about whether it should be.

One more thing the trap sees: every key anybody asks for, including then, toJSON and Symbol.toPrimitive. A default that answers then with a function makes your object look like a promise, and await on it will do something you did not plan. A defaulting proxy should either exclude symbol keys and the well-known names, or be kept well away from anything that awaits.

111 mapLimit, a ceiling on how much runs at once

hard

Write mapLimit(items, limit, worker): run an async worker over a list with at most limit of them in flight, and return the results in the order of the input. Drive it with an injected clock instead of real timers, and prove the ceiling held, the results kept their order, and the wall time was the ceiling's rather than the sum.

Stuck? Three hints, smallest first
  1. 1. Do not slice the list into batches. A batch waits for its slowest member; a pool of workers pulling from a shared cursor does not.
  2. 2. Start limit loops, each one taking the next index off a shared counter until the list runs out, and Promise.all the loops.
  3. 3. Write into results[i] rather than pushing, and the output order stops depending on which worker finished first.

The answer

/* A clock you drive by hand. advance() drains pending microtasks BEFORE moving the
   time, so work that is about to register its next wait does so against the right now. */
const makeClock = () => {
  let now = 0;
  let seq = 0;
  const timers = [];
  const drainMicrotasks = async () => {
    for (let i = 0; i < 50; i++) await Promise.resolve();
  };
  return {
    now: () => now,
    wait: (ms) => new Promise((resolve) => timers.push({ at: now + ms, seq: seq++, resolve })),
    async runAll() {
      await drainMicrotasks();
      while (timers.length) {
        timers.sort((a, b) => a.at - b.at || a.seq - b.seq);
        const next = timers.shift();
        now = next.at;
        next.resolve();
        await drainMicrotasks();
      }
      return now;
    },
  };
};

const mapLimit = async (items, limit, worker) => {
  const results = new Array(items.length);
  let cursor = 0;
  const runner = async () => {
    while (cursor < items.length) {
      const i = cursor++;                       // claim an index, then await
      results[i] = await worker(items[i], i);
    }
  };
  const pool = Array.from({ length: Math.min(limit, items.length) }, runner);
  await Promise.all(pool);
  return results;
};

const routes = [
  { name: 'yaba', ms: 30 },
  { name: 'ikeja', ms: 10 },
  { name: 'lekki', ms: 20 },
  { name: 'ojota', ms: 5 },
  { name: 'apapa', ms: 15 },
];

const quoteAll = async (limit) => {
  const clock = makeClock();
  const finished = [];
  let inFlight = 0;
  let peak = 0;
  const quote = async (route) => {
    inFlight++;
    peak = Math.max(peak, inFlight);
    await clock.wait(route.ms);
    inFlight--;
    finished.push(route.name);
    return `${route.name}:${route.ms}`;
  };
  const pending = mapLimit(routes, limit, quote);
  const finishedAt = await clock.runAll();
  const results = await pending;
  return { results, finished, peak, finishedAt };
};

const two = await quoteAll(2);
const serial = await quoteAll(1);
const wideOpen = await quoteAll(5);
const overshoot = await quoteAll(50);
const orderIsStable = same(two.results, serial.results) && same(two.results, wideOpen.results);

console.log('limit 2 :', two.finished, '| peak:', two.peak, '| clock:', two.finishedAt);
console.log('limit 1 :', serial.finished, '| clock:', serial.finishedAt);
console.log('limit 5 :', wideOpen.finished, '| clock:', wideOpen.finishedAt);
console.log('results :', two.results);

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('results come back in the order of the input, whatever the finishing order', two.results, ['yaba:30', 'ikeja:10', 'lekki:20', 'ojota:5', 'apapa:15']);
test('never more than two in flight, even though five were queued', two.peak, 2);
test('they finished in duration order, not input order', two.finished, ['ikeja', 'yaba', 'lekki', 'ojota', 'apapa']);
test('and the batch took 45 on the injected clock, not the 80 a serial run would cost', two.finishedAt, 45);
test('a limit of one is exactly serial: input order, and the sum of the durations', [serial.peak, serial.finished, serial.finishedAt], [1, ['yaba', 'ikeja', 'lekki', 'ojota', 'apapa'], 80]);
test('a limit as wide as the list starts everything, so the total is the longest job', [wideOpen.peak, wideOpen.finishedAt], [5, 30]);
test('a limit larger than the list starts one worker per item and no more', [overshoot.peak, overshoot.finishedAt], [5, 30]);
test('only the timing changed: every limit produced the same answers', orderIsStable, true);

The pool is the idea. Chunking the list into groups of limit and awaiting each group is the obvious approach and it is measurably worse: every chunk waits for its slowest member before the next one starts, so one slow item stalls a whole group. Workers pulling from a shared cursor keep the pipe full — the moment ikeja finishes at 10, that worker takes lekki, without waiting for yaba.

cursor++ claims an index synchronously, before any await, which is what makes the shared counter safe. There are no threads here: between the read and the increment nothing else can run, because nothing else runs until this function reaches an await. Move the increment after the await and two workers happily take the same item.

Writing to results[i] rather than pushing is what separates completion order from result order, and the assertions keep the two apart on purpose: the finishing order is by duration, the result order is by input, and the answers are identical for every limit. The limit only buys you time — and protection, since the reason to cap concurrency is usually a server that will rate-limit you or a phone that will run out of sockets. Note also that Promise.all over the pool means one rejection abandons the batch with results half-filled; wrap the worker in a try/catch and record failures if you want the rest to continue.

112 retry with exponential backoff, asserted as a list of delays

hard

Write retry(operation, options) that reattempts a failing async operation, waiting base * factor ** (attempt - 1) between tries and capping at maxDelay. Take the clock as an argument so nothing waits on wall time, report the delays it asked for, and show that there is no wait after the final attempt.

Stuck? Three hints, smallest first
  1. 1. The loop is for (let attempt = 1; attempt <= attempts; attempt++) with the operation in a try and the wait in the catch.
  2. 2. Check for the last attempt before waiting. Waiting after the attempt you will not retry is a delay nobody ever benefits from.
  3. 3. Do not measure the delays with a real clock. Report them through a callback and assert the list — [100, 200, 400] is a much better test than a stopwatch.

The answer

const makeClock = () => {
  let now = 0;
  let seq = 0;
  const timers = [];
  const drainMicrotasks = async () => {
    for (let i = 0; i < 50; i++) await Promise.resolve();
  };
  return {
    now: () => now,
    wait: (ms) => new Promise((resolve) => timers.push({ at: now + ms, seq: seq++, resolve })),
    async runAll() {
      await drainMicrotasks();                  // drain BEFORE the clock moves
      while (timers.length) {
        timers.sort((a, b) => a.at - b.at || a.seq - b.seq);
        const next = timers.shift();
        now = next.at;
        next.resolve();
        await drainMicrotasks();
      }
      return now;
    },
  };
};

const retry = async (operation, options) => {
  const { attempts, base, factor = 2, maxDelay = Infinity, clock, onWait } = options;
  let lastError;
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      return await operation(attempt);
    } catch (error) {
      lastError = error;
      if (attempt === attempts) break;          // no wait after the try you will not repeat
      const delay = Math.min(base * factor ** (attempt - 1), maxDelay);
      if (onWait) onWait(delay);
      await clock.wait(delay);
    }
  }
  throw lastError;
};

const flaky = (failures) => {
  let calls = 0;
  return async (attempt) => {
    calls++;
    if (calls <= failures) throw new Error(`attempt ${attempt} failed`);
    return `ok on attempt ${attempt}`;
  };
};

const attempt = async (options, failures) => {
  const clock = makeClock();
  const delays = [];
  let value = 'no value';
  let failure = 'no error';
  const settled = retry(flaky(failures), { ...options, clock, onWait: (d) => delays.push(d) })
    .then((v) => { value = v; }, (e) => { failure = e.message; });
  await clock.runAll();
  await settled;
  return { value, failure, delays, at: clock.now() };
};

const doubling = await attempt({ attempts: 5, base: 100 }, 3);
const capped = await attempt({ attempts: 5, base: 100, maxDelay: 250 }, 3);
const flat = await attempt({ attempts: 4, base: 100, factor: 1 }, 3);
const hopeless = await attempt({ attempts: 3, base: 100 }, 99);
const firstTime = await attempt({ attempts: 5, base: 100 }, 0);

console.log('doubling :', doubling.delays, '->', doubling.value, '| clock:', doubling.at);
console.log('capped   :', capped.delays, '| clock:', capped.at);
console.log('flat     :', flat.delays);
console.log('hopeless :', hopeless.delays, '->', hopeless.failure);

Output


      
8 checks that prove this answer

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

8 checks

test('three failures then a success on the fourth attempt', doubling.value, 'ok on attempt 4');
test('the waits doubled each time', doubling.delays, [100, 200, 400]);
test('and the whole thing cost 700 on the injected clock, with nothing really waited for', doubling.at, 700);
test('maxDelay stops the growth without stopping the retries', [capped.delays, capped.at], [[100, 200, 250], 550]);
test('a factor of 1 is a fixed delay, which is the version that stampedes a server', flat.delays, [100, 100, 100]);
test('when every attempt fails, the last error is what comes out', hopeless.failure, 'attempt 3 failed');
test('and three attempts mean two waits: nothing sleeps after the final failure', [hopeless.delays, hopeless.at], [[100, 200], 300]);
test('an operation that works first time waits for nothing at all', [firstTime.value, firstTime.delays, firstTime.at], ['ok on attempt 1', [], 0]);

The reason backoff exists is that the usual cause of a failure is load, and retrying immediately adds load. Doubling gives the far end room to recover, and maxDelay stops the doubling from turning a transient blip into a nine-minute wait. A factor of 1 is the version to recognise and avoid: every client retries on the same fixed rhythm, so they all come back together and knock the server over again.

Injecting the clock is what makes this testable. With setTimeout the honest test of a doubling backoff takes seven hundred milliseconds and asserts almost nothing; with an injected clock the delays are a list of numbers, and [100, 200, 400] is a stronger statement than any elapsed-time check. The rule that makes a fake clock work is unglamorous: drain the pending microtasks before moving the time forward, so code that is mid-attempt has already registered its next wait and registers it against the right now.

Two details the assertions pin down. Three attempts mean two waits, because sleeping after the attempt you have already decided not to repeat is pure latency at the worst possible moment. And a real implementation needs one thing left out here for determinism: jitter. Multiply each delay by a random factor so that a thousand clients that failed together do not come back in lockstep. It is also worth deciding what to retry — a 500 or a dropped connection, yes; a 400 or a validation failure, never, because the answer will not change.

113 a queue that runs promises one at a time, in order

hard

Write a queue whose add(task) returns a promise for that task's result but guarantees the tasks themselves run one after another, in the order added. Show that nothing starts synchronously, that a rejected task does not break the chain, and how differently the same three tasks behave under Promise.all.

Stuck? Three hints, smallest first
  1. 1. Keep a single promise as the tail of the chain. add is tail.then(task), and the new tail is what that produced.
  2. 2. If a task rejects, the tail must not stay rejected or every later task is skipped. Give the tail its own catch, separate from the promise you hand back.
  3. 3. The caller still needs the real result, including the rejection, so return the un-caught promise and keep the swallowed one for the chain.

The answer

const makeClock = () => {
  let now = 0;
  let seq = 0;
  const timers = [];
  const drainMicrotasks = async () => {
    for (let i = 0; i < 50; i++) await Promise.resolve();
  };
  return {
    now: () => now,
    wait: (ms) => new Promise((resolve) => timers.push({ at: now + ms, seq: seq++, resolve })),
    async runAll() {
      await drainMicrotasks();                  // microtasks first, then the clock
      while (timers.length) {
        timers.sort((a, b) => a.at - b.at || a.seq - b.seq);
        const next = timers.shift();
        now = next.at;
        next.resolve();
        await drainMicrotasks();
      }
      return now;
    },
  };
};

class SerialQueue {
  #tail = Promise.resolve();
  #depth = 0;

  add(task) {
    this.#depth++;
    const run = this.#tail.then(task);          // wait for whatever is ahead of you
    this.#tail = run.catch(() => {});           // a failure must not poison the chain
    return run.finally(() => { this.#depth--; });
  }

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

const clock = makeClock();
const started = [];
const finished = [];
const job = (name, ms) => async () => {
  started.push(name);
  await clock.wait(ms);
  finished.push(name);
  return `${name} done`;
};

const queue = new SerialQueue();
const slow = queue.add(job('upload-receipt', 30));
const quick = queue.add(job('send-sms', 5));
const third = queue.add(job('update-ledger', 10));
const startedSynchronously = [...started];
const depthWhileQueued = queue.depth;

const serialAt = await clock.runAll();
const queued = await Promise.all([slow, quick, third]);
const depthAtTheEnd = queue.depth;

const parallelClock = makeClock();
const pStarted = [];
const pFinished = [];
const pJob = (name, ms) => async () => {
  pStarted.push(name);
  await parallelClock.wait(ms);
  pFinished.push(name);
  return name;
};
const together = Promise.all([pJob('upload-receipt', 30)(), pJob('send-sms', 5)(), pJob('update-ledger', 10)()]);
const parallelAt = await parallelClock.runAll();
const parallelResults = await together;

const failClock = makeClock();
const trail = [];
const failQueue = new SerialQueue();
const one = failQueue.add(async () => { trail.push('one'); await failClock.wait(5); return 'one'; });
const boom = failQueue.add(async () => { trail.push('two'); await failClock.wait(5); throw new Error('network down'); });
const three = failQueue.add(async () => { trail.push('three'); await failClock.wait(5); return 'three'; });
const rescued = boom.then(() => 'resolved after all', (e) => e.message);
await failClock.runAll();
const outcomes = [await one, await rescued, await three];

console.log('started :', started, '| synchronously:', startedSynchronously);
console.log('finished:', finished, '| clock:', serialAt);
console.log('parallel:', pFinished, '| clock:', parallelAt);
console.log('after a failure:', trail, outcomes);

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('add starts nothing synchronously: the first task waits for a microtask', [startedSynchronously, depthWhileQueued], [[], 3]);
test('they started in the order they were added, one at a time', started, ['upload-receipt', 'send-sms', 'update-ledger']);
test('and finished in that order too, though the first was six times the slowest', finished, ['upload-receipt', 'send-sms', 'update-ledger']);
test('so the clock advanced by the sum of the durations, which is what serial costs', serialAt, 45);
test('every caller still got its own result back', [queued, depthAtTheEnd], [['upload-receipt done', 'send-sms done', 'update-ledger done'], 0]);
test('Promise.all starts all three at once, so they finish by duration', pFinished, ['send-sms', 'update-ledger', 'upload-receipt']);
test('its clock cost is the longest task, not the sum, and results keep input order', [parallelAt, parallelResults], [30, ['upload-receipt', 'send-sms', 'update-ledger']]);
test('a rejected task does not break the queue: the one behind it still ran', trail, ['one', 'two', 'three']);
test('and the rejection reached its own caller, not the queue', outcomes, ['one', 'network down', 'three']);

The queue is one promise long. tail.then(task) says "run this after whatever is currently last", and the result becomes the new last. That is the entire mechanism, and it gives you ordering for free without a list, a lock or a scheduler.

The two-promise split in add is the part that is easy to get wrong. The chain's tail must be a promise that cannot reject, or one failing task leaves the tail rejected and every later then skips its callback — the queue silently stops working. But the caller does need the rejection, so add returns the un-caught promise and keeps a caught copy for itself. Two references to the same work, with two different error policies.

Set against Promise.all, the trade is explicit: the queue costs the sum of the durations and Promise.all costs the longest. Serialising is not a performance choice, it is a correctness one — writes that must not interleave, a device that accepts one command at a time, an API that rate-limits per connection. Note also that started is empty immediately after three add calls: then schedules a microtask, so even the first task begins after the current synchronous block finishes. If that matters, the first task can be started eagerly, but then add stops being uniform.

114 cancelling work with an AbortController

hard

Make a multi-step transfer cancellable with an AbortController. Each step waits on an injected clock, and the wait must lose a race against the abort. Show what happens when the signal is already aborted before you start, what a bare abort() throws, and that the abandoned wait still fires.

Stuck? Three hints, smallest first
  1. 1. A cancellable wait is a race: one Promise whose resolve the clock calls and whose reject the abort event calls.
  2. 2. Check signal.aborted before subscribing. A signal that is already aborted will never fire the event again, so the listener alone is not enough.
  3. 3. signal.reason is whatever you passed to abort(...), and with no argument it is a DOMException named AbortError.

The answer

const makeClock = () => {
  let now = 0;
  let seq = 0;
  const timers = [];
  const drainMicrotasks = async () => {
    for (let i = 0; i < 50; i++) await Promise.resolve();
  };
  return {
    now: () => now,
    wait: (ms) => new Promise((resolve) => timers.push({ at: now + ms, seq: seq++, resolve })),
    async runAll() {
      await drainMicrotasks();                  // microtasks first, then move the clock
      while (timers.length) {
        timers.sort((a, b) => a.at - b.at || a.seq - b.seq);
        const next = timers.shift();
        now = next.at;
        next.resolve();
        await drainMicrotasks();
      }
      return now;
    },
  };
};

const waitOrAbort = (clock, ms, signal) =>
  new Promise((resolve, reject) => {
    if (signal.aborted) {
      reject(signal.reason);                    // already cancelled: the event will not fire again
      return;
    }
    const onAbort = () => reject(signal.reason);
    signal.addEventListener('abort', onAbort, { once: true });
    clock.wait(ms).then(() => {
      signal.removeEventListener('abort', onAbort);
      resolve();
    });
  });

const steps = [
  { name: 'reserve stock', ms: 10 },
  { name: 'charge card', ms: 20 },
  { name: 'notify rider', ms: 5 },
];

const runTransfer = async ({ abortAt = null, abortFirst = false }) => {
  const clock = makeClock();
  const controller = new AbortController();
  const signal = controller.signal;
  const done = [];
  const transfer = async () => {
    for (const step of steps) {
      await waitOrAbort(clock, step.ms, signal);
      done.push(step.name);
    }
    return done.join(' -> ');
  };
  if (abortFirst) controller.abort(new Error('cancelled before we started'));
  else if (abortAt !== null) clock.wait(abortAt).then(() => controller.abort(new Error('rider cancelled')));
  let value = 'no value';
  let failure = 'no error';
  const settled = transfer().then((v) => { value = v; }, (e) => { failure = e.message; });
  await clock.runAll();
  await settled;
  return { value, failure, done, at: clock.now(), controller };
};

const whole = await runTransfer({});
const cancelled = await runTransfer({ abortAt: 25 });
const early = await runTransfer({ abortFirst: true });

whole.controller.abort(new Error('too late to matter'));
const afterTheFact = [whole.value, whole.controller.signal.aborted, whole.controller.signal.reason.message];

const bare = new AbortController();
bare.abort();
const defaultReason = [bare.signal.aborted, bare.signal.reason.name];

const twice = new AbortController();
twice.abort(new Error('first reason'));
twice.abort(new Error('second reason'));
const keptReason = twice.signal.reason.message;

console.log('whole    :', whole.value, '| clock:', whole.at);
console.log('cancelled:', cancelled.done, '->', cancelled.failure, '| clock:', cancelled.at);
console.log('early    :', early.done, '->', early.failure, '| clock:', early.at);
console.log('bare     :', defaultReason, '| kept:', keptReason);

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('with nobody cancelling, every step ran and the clock advanced 35', [whole.value, whole.at], ['reserve stock -> charge card -> notify rider', 35]);
test('an abort at 25 stops it after the step that had already finished', [cancelled.done, cancelled.failure], [['reserve stock'], 'rider cancelled']);
test('the wait that lost the race still fired at 30, with nobody listening', cancelled.at, 30);
test('a signal that is already aborted fails before any work and never touches the clock', [early.done, early.failure, early.at], [[], 'cancelled before we started', 0]);
test('aborting afterwards flips the signal and changes nothing else', afterTheFact, ['reserve stock -> charge card -> notify rider', true, 'too late to matter']);
test('abort() with no argument gives a DOMException named AbortError', defaultReason, [true, 'AbortError']);
test('aborting twice keeps the first reason', keptReason, 'first reason');
test('a controller owns exactly one signal, which is the handle you pass around', [whole.controller.signal === whole.controller.signal, typeof whole.controller.signal.addEventListener], [true, 'function']);

AbortController is a one-way switch with an event attached, and the split between the two halves is the useful part: you keep the controller, callers get the signal. A signal can only be read and listened to, so handing one out gives away the ability to observe cancellation without the ability to cause it. That is why fetch, the streams API and Node's own APIs all take a signal rather than a controller.

The aborted check before subscribing is not defensive noise. An abort that has already happened will never fire the event again, so a listener-only implementation waits for ever on a signal that was cancelled a moment before it was passed in — which is exactly what happens when a signal is reused or created from a parent. Both paths have to reject, and both have to reject with signal.reason so callers can tell cancellation from failure.

Two things the assertions expose. The abandoned wait still fires: rejecting the race does nothing to the timer underneath, so the clock still ran to 30 and, with a real setTimeout, a handle would still be sitting in the event loop. Cancellation has to clear the timeout, not just stop caring about it, or a busy cancelled path leaks timers and keeps the process awake. And abort() with no argument produces a DOMException named AbortError, which is the string every caller checks against to decide "this was cancelled" rather than "this went wrong" — so if you pass your own reason, keep that distinction visible.

115 Symbol.asyncIterator, paging a source one request at a time

hard

Wrap a cursor-paged data source in an object with Symbol.asyncIterator, so a caller can write for await (const row of vendors) and never think about cursors. Count the requests, prove the pages were fetched in sequence, show that breaking out early stops fetching, and see what a plain spread does to it.

Stuck? Three hints, smallest first
  1. 1. An async * generator method named [Symbol.asyncIterator] is the whole adapter: loop while there is a cursor, await a page, yield its rows one at a time.
  2. 2. Yielding rows rather than pages is what hides the paging. The consumer sees a flat sequence and the next request happens when it asks for the row after the last one on a page.
  3. 3. A break in a for await closes the iterator, so the loop that would have asked for the next page never runs. Count the calls to prove it.

The answer

const makeClock = () => {
  let now = 0;
  let seq = 0;
  const timers = [];
  const drainMicrotasks = async () => {
    for (let i = 0; i < 50; i++) await Promise.resolve();
  };
  return {
    now: () => now,
    wait: (ms) => new Promise((resolve) => timers.push({ at: now + ms, seq: seq++, resolve })),
    async runAll() {
      await drainMicrotasks();                  // drain, then advance
      while (timers.length) {
        timers.sort((a, b) => a.at - b.at || a.seq - b.seq);
        const next = timers.shift();
        now = next.at;
        next.resolve();
        await drainMicrotasks();
      }
      return now;
    },
  };
};

const table = [
  { id: 1, name: 'Ngozi Traders' },
  { id: 2, name: 'Bisi Foods' },
  { id: 3, name: 'Chidi Motors' },
  { id: 4, name: 'Amaka Fabrics' },
  { id: 5, name: 'Tunde Spares' },
];

const makeSource = (clock, pageSize) => {
  let calls = 0;
  return {
    get calls() { return calls; },
    async fetchPage(cursor) {
      calls++;
      await clock.wait(10);                     // one round trip
      return {
        rows: table.slice(cursor, cursor + pageSize),
        next: cursor + pageSize < table.length ? cursor + pageSize : null,
      };
    },
  };
};

const paged = (source) => ({
  async *[Symbol.asyncIterator]() {
    let cursor = 0;
    while (cursor !== null) {
      const page = await source.fetchPage(cursor);
      for (const row of page.rows) yield row;   // hand out rows, not pages
      cursor = page.next;
    }
  },
});

const clockA = makeClock();
const sourceA = makeSource(clockA, 2);
const walkAll = async () => {
  const out = [];
  for await (const row of paged(sourceA)) out.push(row.name);
  return out;
};
const pendingA = walkAll();
await clockA.runAll();
const everyName = await pendingA;
const fullWalk = [sourceA.calls, clockA.now()];

const clockB = makeClock();
const sourceB = makeSource(clockB, 2);
const walkThree = async () => {
  const out = [];
  for await (const row of paged(sourceB)) {
    out.push(row.id);
    if (out.length === 3) break;                // stop asking
  }
  return out;
};
const pendingB = walkThree();
await clockB.runAll();
const threeIds = await pendingB;
const shortWalk = [sourceB.calls, clockB.now()];

const clockC = makeClock();
const sourceC = makeSource(clockC, 5);
const iterator = paged(sourceC)[Symbol.asyncIterator]();
const firstStep = iterator.next();
const stepIsAPromise = typeof firstStep.then === 'function';
await clockC.runAll();
const first = await firstStep;
const second = await iterator.next();
const rest = [];
let step = await iterator.next();
while (!step.done) {
  rest.push(step.value.id);
  step = await iterator.next();
}

let spreadError = 'no error';
try { [...paged(sourceA)]; } catch (e) { spreadError = e.constructor.name; }

const clockE = makeClock();
const sourceE = makeSource(clockE, 5);
const feed = paged(sourceE);
const walkFeed = async () => {
  const out = [];
  for await (const row of feed) out.push(row.id);
  return out;
};
const firstPass = walkFeed();
await clockE.runAll();
const pass1 = await firstPass;
const secondPass = walkFeed();
await clockE.runAll();
const pass2 = await secondPass;

console.log('all rows :', everyName);
console.log('requests :', fullWalk, '| after break:', shortWalk);
console.log('manual   :', first.value.name, second.value.name, rest, '| done:', step.done);
console.log('twice    :', pass1, pass2, '| requests:', sourceE.calls);

Output


      
8 checks that prove this answer

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

8 checks

test('every row across every page arrives, in order, with no cursor in sight', everyName, ['Ngozi Traders', 'Bisi Foods', 'Chidi Motors', 'Amaka Fabrics', 'Tunde Spares']);
test('five rows at two a page took three requests, one after another', fullWalk, [3, 30]);
test('breaking out after three rows stops at the second page', [threeIds, shortWalk], [[1, 2, 3], [2, 20]]);
test('next() hands back a promise of { value, done }', [stepIsAPromise, first.done, first.value.name], [true, false, 'Ngozi Traders']);
test('each call advances one row, buffered from the page already fetched', [second.value.name, rest], ['Bisi Foods', [3, 4, 5]]);
test('when the rows run out, done is true and value is undefined', [step.done, step.value === undefined], [true, true]);
test('a plain spread throws, because that wants Symbol.iterator and this has the async one', spreadError, 'TypeError');
test('the iterable can be walked twice: a fresh iterator each time, so it fetches again', [pass1, pass2, sourceE.calls, clockE.now()], [[1, 2, 3, 4, 5], [1, 2, 3, 4, 5], 2, 20]);

An async iterable is the right shape for anything that arrives in instalments: paged HTTP responses, database cursors, a websocket, lines from a file. The consumer writes one for await loop and the cursor bookkeeping disappears into the adapter. Yielding rows rather than pages is what makes that work — the caller never sees a page boundary, and the next request happens exactly when it asks for the row after the last one it was given.

Laziness is the property being asserted, and the request count is how you see it. The break case does two requests instead of three, so a consumer that only wants the first few rows does not pay for the rest of the table. That is why this beats fetching everything into an array first, and it is the same argument as the synchronous lazy pipeline, one await deeper.

Three details to keep in mind. Symbol.asyncIterator and Symbol.iterator are separate protocols: for await looks for the async one, while spread, destructuring and for...of look for the sync one and throw here. A fresh iterator per call means the object can be walked twice, at the cost of fetching everything again — whereas the iterator you get from [Symbol.asyncIterator]() is single-use, as the manual walk shows. And for await is strictly sequential: it awaits each value before asking for the next, so if you want several pages in flight at once, this is the wrong tool and something like mapLimit over a list of cursors is the right one.

116 a state machine as a frozen lookup table

fair

Model an order's life — placed, packed, dispatched, delivered, cancelled — as one frozen object of states to events to next states. Write next(state, event) so an event the current state does not allow leaves the state alone, feed a list of events through reduce, and collect both the trail of states and the events that were ignored. Then show what Object.freeze did not freeze.

Stuck? Three hints, smallest first
  1. 1. The table is { placed: { pack: 'packed' } }: two lookups, a state then an event.
  2. 2. machine[state]?.[event] ?? state covers an unknown event and an unknown state in one line.
  3. 3. The reducer carries an object, not a string: { state, trail, ignored }, rebuilt with spread each step.

The answer

const machine = Object.freeze({
  placed:     { pack: 'packed', cancel: 'cancelled' },
  packed:     { dispatch: 'dispatched', cancel: 'cancelled' },
  dispatched: { deliver: 'delivered' },
  delivered:  {},
  cancelled:  {},
});

const next = (state, event) => machine[state]?.[event] ?? state;
const eventsFrom = (state) => Object.keys(machine[state] ?? {});
const isTerminal = (state) => eventsFrom(state).length === 0;

const feed = (start, events) => events.reduce((acc, event) => {
  const to = next(acc.state, event);
  return to === acc.state
    ? { ...acc, ignored: [...acc.ignored, event] }
    : { ...acc, state: to, trail: [...acc.trail, to] };
}, { state: start, trail: [start], ignored: [] });

const happy = feed('placed', ['pack', 'dispatch', 'deliver']);
const stubborn = feed('placed', ['deliver', 'pack', 'cancel', 'pack']);
const fromNowhere = next('warehouse-9', 'pack');

let topLevelWrite = 'no error';
try { machine.placed = {}; } catch (e) { topLevelWrite = e.constructor.name; }
const nestedIsFrozen = Object.isFrozen(machine.placed);

console.log('happy    :', happy.state, happy.trail);
console.log('stubborn :', stubborn.state, '| ignored:', stubborn.ignored);
console.log('terminal :', ['delivered', 'cancelled', 'placed'].map(isTerminal));
console.log('freeze   :', topLevelWrite, '| nested frozen:', nestedIsFrozen);

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('the happy path ends delivered', happy.state, 'delivered');
test('and its trail is every state it passed through', happy.trail, ['placed', 'packed', 'dispatched', 'delivered']);
test('nothing was refused along the way', happy.ignored, []);
test('an event the state does not allow is dropped, not an error', [stubborn.state, stubborn.ignored], ['cancelled', ['deliver', 'pack']]);
test('so the trail only records real moves', stubborn.trail, ['placed', 'packed', 'cancelled']);
test('a state with no events left is terminal', ['delivered', 'cancelled', 'placed'].map(isTerminal), [true, true, false]);
test('an unknown state returns itself, thanks to optional chaining', fromNowhere, 'warehouse-9');
test('the table lists what each state can do', eventsFrom('packed'), ['dispatch', 'cancel']);
test('freeze stops a write to the table, but only the top level', [topLevelWrite, nestedIsFrozen], ['TypeError', false]);

A state machine written as data is worth more than the same thing written as ifs, because the table is inspectable: eventsFrom and isTerminal are one line each, and a UI can grey out the buttons for events the current state does not offer. Adding a state is a new key, not a new branch in five places.

machine[state]?.[event] ?? state is doing three jobs. The ?. handles a state that is not in the table, the missing key handles an event that state does not allow, and ?? — not || — supplies the fallback, which matters the day a state is named with a falsy-looking value.

The last assertion is the trap. Object.freeze is one level deep: the table cannot be reassigned, but machine.placed is an ordinary mutable object, so machine.placed.pack = 'lost' would succeed and silently rewire the machine. Exercise 126 fixes that with a recursive freeze.

117 an immutable reducer for a market cart

fair

Write reduce(state, action) for a cart that handles add, drop, qty and note without ever writing to the state it was given. Adding a SKU already in the cart must merge quantities rather than append a second row, and qty: 0 must mean drop. Prove the starting state is untouched, that an unknown action returns the very same object, and that only the parts that changed were rebuilt.

Stuck? Three hints, smallest first
  1. 1. Every branch ends in { ...state, items: something }. No push, no splice, no assignment to a property of state.
  2. 2. To bump one row, map the array and spread only the matching item: the others come out as the same objects.
  3. 3. A branch that changes nothing should return state itself, not a copy — that is what lets a consumer compare with ===.

The answer

const empty = Object.freeze({ items: [], note: '' });

const reduce = (state, action) => {
  switch (action.type) {
    case 'add': {
      const seen = state.items.some((i) => i.sku === action.sku);
      const items = seen
        ? state.items.map((i) => (i.sku === action.sku ? { ...i, qty: i.qty + action.qty } : i))
        : [...state.items, { sku: action.sku, name: action.name, qty: action.qty, kobo: action.kobo }];
      return { ...state, items };
    }
    case 'drop':
      return { ...state, items: state.items.filter((i) => i.sku !== action.sku) };
    case 'qty':
      return action.qty > 0
        ? { ...state, items: state.items.map((i) => (i.sku === action.sku ? { ...i, qty: action.qty } : i)) }
        : reduce(state, { type: 'drop', sku: action.sku });
    case 'note':
      return { ...state, note: action.text };
    default:
      return state;
  }
};

const feed = (state, actions) => actions.reduce(reduce, state);
const totalKobo = (state) => state.items.reduce((sum, i) => sum + i.qty * i.kobo, 0);

const stocked = feed(empty, [
  { type: 'add', sku: 'GARRI-5', name: 'Garri, 5kg', qty: 2, kobo: 450000 },
  { type: 'add', sku: 'PALM-1', name: 'Palm oil, 1L', qty: 1, kobo: 320000 },
  { type: 'add', sku: 'GARRI-5', qty: 1 },
]);

const noted = reduce(stocked, { type: 'note', text: 'Leave it at the gate' });
const zeroed = reduce(stocked, { type: 'qty', sku: 'PALM-1', qty: 0 });
const ignored = reduce(stocked, { type: 'sing' });

let frozenWrite = 'no error';
try { empty.note = 'hijacked'; } catch (e) { frozenWrite = e.constructor.name; }

console.log('cart  :', stocked.items.map((i) => `${i.name} x${i.qty}`));
console.log('total :', totalKobo(stocked), 'kobo');
console.log('shared:', noted.items === stocked.items, '| same object back:', ignored === stocked);

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('three adds, two rows, because the repeat merged', stocked.items.map((i) => [i.sku, i.qty]), [['GARRI-5', 3], ['PALM-1', 1]]);
test('the merge kept the name and price from the first add', stocked.items[0].name, 'Garri, 5kg');
test('the total reads the rebuilt rows', totalKobo(stocked), 1670000);
test('a quantity of zero is a removal', zeroed.items.map((i) => i.sku), ['GARRI-5']);
test('and the state we started from never changed', [empty.items.length, empty.note], [0, '']);
test('an action nobody handles gives back the identical object', ignored === stocked, true);
test('changing the note rebuilt the wrapper and reused the rows', [noted !== stocked, noted.items === stocked.items], [true, true]);
test('under strict mode a write to the frozen start throws', frozenWrite, 'TypeError');
test('though the array inside it was never frozen at all', Object.isFrozen(empty.items), false);

Two rules make a reducer safe to hand to a UI. Never write to the argument, and return the argument itself when nothing changed. The second is what makes the cheap comparison work: a consumer can skip re-rendering a list when next.items === prev.items, and that is only true if the branch that changed the note left the array alone. Spread gives you that for free — it copies one level, so untouched rows come through as the same objects.

qty: 0 delegating to drop is worth copying. Calling the reducer from inside itself keeps one definition of what removal means, so a later change to drop cannot leave the two paths disagreeing.

Object.freeze on the starting state is a seatbelt, and only for the top level: the assignment throws under strict mode, but empty.items is a plain array that push would happily mutate. Freezing tells you about your own mistakes early; it is not a deep guarantee.

118 collect every validation error, do not throw on the first

fair

Validate a delivery form against a list of rules and return all the failures at once, as { ok: false, errors: [{ field, message }] }. Write the throwing version beside it and show that it can only ever report one problem. Then show that the order of the rules changes the order of the report but not the set of failures.

Stuck? Three hints, smallest first
  1. 1. A rule is data: a field name, a message and a predicate. Then validation is filter then map.
  2. 2. errors.length === 0 is the success test. Returning { ok: true, value } keeps callers from having to guess.
  3. 3. The eager version needs throw inside a loop, so the second failing rule never runs.

The answer

const STATES = new Set(['Lagos', 'Kano', 'Rivers', 'Enugu', 'FCT']);

const rules = [
  ['name', 'name is required', (f) => typeof f.name === 'string' && f.name.trim() !== ''],
  ['phone', 'phone must be 11 digits starting with 0', (f) => /^0\d{10}$/.test(f.phone ?? '')],
  ['state', 'state is not on the list', (f) => STATES.has(f.state)],
  ['age', 'age must be a whole number, 18 or more', (f) => Number.isInteger(f.age) && f.age >= 18],
  ['email', 'email needs exactly one @', (f) => (f.email ?? '').split('@').length === 2],
];

const check = (form) => {
  const errors = rules
    .filter(([, , ok]) => !ok(form))
    .map(([field, message]) => ({ field, message }));
  return errors.length === 0 ? { ok: true, value: form } : { ok: false, errors };
};

const firstFailureOnly = (form) => {
  try {
    for (const [field, message, ok] of rules) {
      if (!ok(form)) throw new Error(`${field}: ${message}`);
    }
    return { ok: true };
  } catch (e) {
    return { ok: false, message: e.message };
  }
};

const good = { name: 'Ngozi Okafor', phone: '08031234567', state: 'Enugu', age: 31, email: 'ngozi@example.ng' };
const wrong = { name: '   ', phone: '8031234567', state: 'Lagos Island', age: 17, email: 'ngozi.example.ng' };
const nearly = { name: 'Musa Bello', phone: '07098765432', state: 'Kano', age: 16, email: 'musa@example.ng' };

const passed = check(good);
const failed = check(wrong);
const fields = failed.errors.map((e) => e.field);
const byField = failed.errors.reduce((acc, e) => ({ ...acc, [e.field]: e.message }), {});
const eager = firstFailureOnly(wrong);
const reversedFields = [...rules].reverse().filter(([, , ok]) => !ok(wrong)).map(([f]) => f);

console.log('passed :', passed);
console.log('failed :', fields);
console.log('eager  :', eager.message);
console.log('byField:', byField);

Output


      
9 checks that prove this answer

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

9 checks

test('a form that satisfies every rule comes back with its value', [passed.ok, passed.value.name], [true, 'Ngozi Okafor']);
test('and carries no errors key at all', 'errors' in passed, false);
test('a bad form reports every failure in one pass', [failed.ok, failed.errors.length], [false, 5]);
test('in the order the rules were declared', fields, ['name', 'phone', 'state', 'age', 'email']);
test('which reduces neatly into a lookup for a form UI', byField.age, 'age must be a whole number, 18 or more');
test('the throwing version can only ever tell you about the first', eager.message, 'name: name is required');
test('one bad field means one error, not a wall of them', check(nearly).errors.map((e) => e.field), ['age']);
test('rule order changes the report order, not which rules failed', [...reversedFields].reverse(), fields);
test('and an empty object fails all five', check({}).errors.length, 5);

Throwing on the first bad field is the wrong shape for a form. The user fixes one thing, submits, and is told about the next — five round trips for five mistakes. Collecting is barely more code: filter the rules that failed, map them to a report, and let errors.length decide the verdict.

Making a rule data rather than a line of code is what buys the rest. The rules can be reordered, filtered by field, counted, or serialised to the client, and the assertion about reversal proves the important property: the collector is order-insensitive about what it finds. Notice that f.phone ?? '' keeps a missing field from crashing the predicate — a validator that throws on absent input is not a validator.

Keep the two success shapes distinguishable. { ok: true, value } and { ok: false, errors } mean a caller writes if (!result.ok) and can never accidentally read a half-valid value, which is exactly the mistake a bare errors array invites.

119 a schema validator built from small predicates

hard

Build a validator out of composable pieces: str, int, oneOf, optional, arrayOf and shape. Each one takes a value and returns an array of errors — empty means valid — so nesting them is just flatMap. Every error must carry a dotted path such as goods.1.kobo, and one call must report all of them.

Stuck? Three hints, smallest first
  1. 1. Make the contract "returns an array of errors". Then combining validators is concatenating arrays, which flatMap does for you.
  2. 2. shape walks Object.entries(fields), runs each rule on value[key], and prefixes the paths that come back with key.
  3. 3. optional(rule) is one line: v === undefined ? [] : rule(v). Every other rule can then assume the value is present.

The answer

const at = (path, message) => ({ path, message });
const prefix = (head, errors) =>
  errors.map((e) => ({ ...e, path: e.path ? `${head}.${e.path}` : head }));

const str = ({ min = 0 } = {}) => (v) =>
  typeof v !== 'string' ? [at('', 'must be a string')]
    : v.length < min ? [at('', `must be at least ${min} characters`)]
      : [];

const int = ({ min = -Infinity } = {}) => (v) =>
  !Number.isInteger(v) ? [at('', 'must be a whole number')]
    : v < min ? [at('', `must be at least ${min}`)]
      : [];

const oneOf = (allowed) => (v) =>
  allowed.includes(v) ? [] : [at('', `must be one of ${allowed.join(', ')}`)];

const optional = (rule) => (v) => (v === undefined ? [] : rule(v));

const arrayOf = (rule) => (v) =>
  !Array.isArray(v)
    ? [at('', 'must be an array')]
    : v.flatMap((item, i) => prefix(String(i), rule(item)));

const shape = (fields) => (v) =>
  v === null || typeof v !== 'object'
    ? [at('', 'must be an object')]
    : Object.entries(fields).flatMap(([key, rule]) => prefix(key, rule(v[key])));

const validator = (rule) => (v) => {
  const errors = rule(v);
  return errors.length ? { ok: false, errors } : { ok: true, value: v };
};

const checkVendor = validator(shape({
  name: str({ min: 3 }),
  lga: oneOf(['Eti-Osa', 'Ikeja', 'Surulere']),
  stall: optional(str({ min: 1 })),
  goods: arrayOf(shape({ item: str({ min: 2 }), kobo: int({ min: 1 }) })),
}));

const goodVendor = { name: 'Mama Nkechi', lga: 'Ikeja', goods: [{ item: 'Ugu', kobo: 50000 }] };
const badVendor = { name: 'Yo', lga: 'Ajah', goods: [{ item: 'Ugu', kobo: 0 }, { item: 7, kobo: 1.5 }] };

const bad = checkVendor(badVendor);
const paths = bad.errors.map((e) => e.path);

console.log('good :', checkVendor(goodVendor));
console.log('paths:', paths);
console.log('first:', bad.errors[0]);

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 vendor that fits the schema passes and hands back the value', [checkVendor(goodVendor).ok, checkVendor(goodVendor).value.lga], [true, 'Ikeja']);
test('a bad one reports every problem, addressed by path', paths, ['name', 'lga', 'goods.0.kobo', 'goods.1.item', 'goods.1.kobo']);
test('the messages come from whichever small rule failed', bad.errors[1].message, 'must be one of Eti-Osa, Ikeja, Surulere');
test('a missing optional field is silent', checkVendor({ name: 'Baba Ali', lga: 'Ikeja', goods: [] }).ok, true);
test('but a present one is checked like any other', checkVendor({ name: 'Baba Ali', lga: 'Ikeja', stall: 9, goods: [] }).errors, [{ path: 'stall', message: 'must be a string' }]);
test('arrayOf on something that is not an array says so once', checkVendor({ name: 'Baba Ali', lga: 'Ikeja', goods: 'none' }).errors, [{ path: 'goods', message: 'must be an array' }]);
test('the top level reports an empty path, since there is nothing to name', checkVendor(null).errors, [{ path: '', message: 'must be an object' }]);
test('and any rule still works on its own, outside a schema', [str({ min: 3 })('Ojo'), int({ min: 1 })(0).length], [[], 1]);

The whole design rests on one decision: a validator returns errors, not a boolean. Booleans do not compose — two of them give you no way to say which field, at which index, failed why. An array of { path, message } concatenates, and concatenation is what flatMap is for, so shape and arrayOf are three lines each and nest to any depth without extra machinery.

prefix is the only fiddly part, and only because of the empty path at the leaves. A leaf rule does not know its own name, so it reports path: '' and each enclosing combinator prepends the key or index as the error travels outward. That is how goods.1.kobo gets built without any rule knowing where it sits.

Each rule is a factory — str({ min: 3 }) returns the validator — which is what lets the schema read like a description of the data. The last assertion is the payoff: nothing about a rule depends on the schema, so it can be tested, reused and combined on its own.

120 parse CSV with quoted fields

hard

Parse CSV properly: fields wrapped in double quotes may contain commas, newlines and doubled quotes standing for one literal quote, and rows may end with CRLF or LF. Walk the text a character at a time with a single quoted flag, then turn the rows into objects keyed by the header. Show what text.split does to the same input.

Stuck? Three hints, smallest first
  1. 1. One boolean is enough state. Inside quotes, a comma and a newline are ordinary characters; outside, they end a field and a row.
  2. 2. A doubled "" inside a quoted field is one quote and stays inside it, so check the NEXT character before deciding a quote closed the field.
  3. 3. After the loop, flush whatever is still in hand — otherwise a file with no trailing newline loses its last row.

The answer

const parseCsv = (text) => {
  const rows = [];
  let row = [];
  let field = '';
  let quoted = false;
  let i = 0;
  while (i < text.length) {
    const ch = text[i];
    if (quoted) {
      if (ch === '"' && text[i + 1] === '"') { field += '"'; i += 2; continue; }
      if (ch === '"') { quoted = false; i += 1; continue; }
      field += ch; i += 1; continue;
    }
    if (ch === '"') { quoted = true; i += 1; continue; }
    if (ch === ',') { row.push(field); field = ''; i += 1; continue; }
    if (ch === '\n' || ch === '\r') {
      if (ch === '\r' && text[i + 1] === '\n') i += 1;
      row.push(field); rows.push(row); row = []; field = ''; i += 1; continue;
    }
    field += ch; i += 1;
  }
  if (field !== '' || row.length) { row.push(field); rows.push(row); }
  return rows;
};

const toObjects = ([header, ...body]) =>
  body.map((cells) => Object.fromEntries(header.map((key, i) => [key, cells[i] ?? ''])));

const csv = [
  'name,lga,crates,note',
  '"Adebayo, Tunde",Ikeja,2,"said ""come early"""',
  'Ngozi Okafor,Surulere,1,',
  '"Musa Bello",Kano,3,"two lines:\nthe second"',
].join('\r\n');

const rows = parseCsv(csv);
const records = toObjects(rows);
const naive = csv.split('\n')[1].split(',').length;

console.log('rows   :', rows.length, '| cells per row:', rows.map((r) => r.length));
console.log('record :', records[0]);
console.log('naive  :', naive, 'cells for a line that has 4 fields');

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('four rows, header included, and no phantom row at the end', rows.length, 4);
test('a comma inside quotes belongs to the field', rows[1][0], 'Adebayo, Tunde');
test('a doubled quote unescapes to one', rows[1][3], 'said "come early"');
test('a trailing comma leaves a real empty field behind', rows[2], ['Ngozi Okafor', 'Surulere', '1', '']);
test('a newline inside quotes stays in the value', rows[3][3].split('\n'), ['two lines:', 'the second']);
test('CRLF is consumed as one row break, so no stray carriage returns', rows.flat().some((c) => c.includes('\r')), false);
test('the header turns the rows into records, all four the same width', [records[0].lga, new Set(rows.map((r) => r.length)).size], ['Ikeja', 1]);
test('splitting on commas gets that same line wrong', naive, 5);
test('and everything parsed is a string: crates is text until you convert it', typeof records[1].crates, 'string');

CSV looks like a one-liner and is not. split(',') breaks on the first quoted comma, and split('\n') breaks on the first quoted newline — both of which appear in real address and note columns constantly. A character walk with one quoted flag is the smallest thing that is actually correct, and it is about twenty lines.

Three details are where hand-written parsers usually leak. The doubled quote has to be checked before the closing quote, or ""come early"" ends the field early. CRLF must be treated as one break, or every last cell carries an invisible \r that ruins comparisons later. And the flush after the loop is what keeps the final row when the file has no trailing newline — while the guard on it, field !== '' || row.length, is what stops a file that does end in a newline from gaining an empty row.

Every value out of a parser like this is a string. crates is '2', not 2, and an empty cell is '', not null — conversion and meaning are the next layer's job, and keeping them out of the parser is what lets it stay this small.

121 a query string, parsed and rebuilt

fair

Parse a URL query string by hand: strip the leading ?, split on & then on the first =, turn + into a space, decode the percent escapes, and collect a key that appears twice into an array. Then write the builder that goes the other way, dropping undefined, and show that a round trip preserves the meaning even though it does not preserve the string.

Stuck? Three hints, smallest first
  1. 1. Split each pair on = with a rest element, then rejoin the rest — a value is allowed to contain =.
  2. 2. + means a space only in a query string, and decodeURIComponent does not know that, so replace it first.
  3. 3. For the builder, flatMap turns { lga: [a, b] } back into two pairs before anything is encoded.

The answer

const parseQuery = (search) =>
  search
    .replace(/^\?/, '')
    .split('&')
    .filter(Boolean)
    .reduce((acc, pair) => {
      const [rawKey, ...rest] = pair.split('=');
      const decode = (s) => decodeURIComponent(s.replace(/\+/g, ' '));
      const key = decode(rawKey);
      const value = decode(rest.join('='));
      const prior = acc[key];
      const merged = !(key in acc) ? value
        : Array.isArray(prior) ? [...prior, value]
          : [prior, value];
      return { ...acc, [key]: merged };
    }, {});

const buildQuery = (params) =>
  Object.entries(params)
    .flatMap(([key, value]) => (Array.isArray(value) ? value.map((one) => [key, one]) : [[key, value]]))
    .filter(([, value]) => value !== undefined && value !== null)
    .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
    .join('&');

const search = '?state=Lagos&lga=Eti-Osa&lga=Ikeja&q=jollof+rice&price=%E2%82%A65%2C000&flag';
const parsed = parseQuery(search);
const rebuilt = buildQuery(parsed);
const reparsed = parseQuery(rebuilt);

const injected = buildQuery({ q: 'rice&admin=1', page: 2, cursor: undefined, note: null });
const injectedBack = parseQuery(injected);

console.log('parsed  :', parsed);
console.log('rebuilt :', rebuilt);
console.log('injected:', injected);

Output


      
9 checks that prove this answer

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

9 checks

test('a plain pair decodes to a string', parsed.state, 'Lagos');
test('a key that appears twice collects into an array, in order', parsed.lga, ['Eti-Osa', 'Ikeja']);
test('plus is a space, but only here', parsed.q, 'jollof rice');
test('percent escapes decode, naira sign and comma included', parsed.price, '₦5,000');
test('a bare key with no equals gets an empty string, not undefined', parsed.flag, '');
test('rebuilding then reparsing gives back the same meaning', reparsed, parsed);
test('but not the same text: a space normalises to %20 and flag gains an =', rebuilt === search, false);
test('encoding is what stops a value forging an extra pair', Object.keys(injectedBack), ['q', 'page']);
test('so the ampersand stays inside the value it came from', injectedBack.q, 'rice&admin=1');

URLSearchParams exists and you should normally use it. Writing this once tells you what it is doing for you: the + rule, the collection of repeated keys, and the encoding that keeps a value from turning into structure. That last one is the security-shaped part — q=rice&admin=1 unencoded is two parameters, and the assertion above proves the builder keeps it as one.

Two small decisions carry weight. Splitting on = with a rest element and rejoining is what allows a base64 value with padding to survive. And a key with no = becoming '' rather than undefined or true is a choice — the platform's own answer is the empty string, which is worth matching so callers behave the same either way.

The round trip is faithful in meaning but not in bytes. + comes back as %20, a bare key gains a trailing =, and key order follows insertion order of the object. Any code comparing query strings as text — a cache key, a signature — has to canonicalise first, or it will treat two identical requests as different.

122 a four-operator expression evaluator

hard

Evaluate arithmetic from a string: + - * /, parentheses, decimals and unary minus, with multiplication binding tighter than addition. Tokenise with one regular expression, then write recursive descent — expr calls term calls primary, and primary calls back into expr for a bracket. Report a useful message on malformed input, including trailing junk.

Stuck? Three hints, smallest first
  1. 1. One match with alternation gives the tokens: a number, an operator or a parenthesis, or any single non-space character so bad input is visible rather than skipped.
  2. 2. Precedence is the nesting of the functions. expr loops over + - and each side is a term; term loops over * / and each side is a primary.
  3. 3. Keep the position in a closed-over at, with peek() and eat(token). After the top-level call, check that every token was consumed.

The answer

const tokenise = (text) => text.match(/\d+(?:\.\d+)?|[-+*/()]|\S/g) ?? [];

const evaluate = (text) => {
  const tokens = tokenise(text);
  let at = 0;
  const peek = () => tokens[at];
  const eat = (want) => {
    if (peek() !== want) throw new SyntaxError(`expected ${want}, found ${peek() ?? 'end of input'}`);
    at += 1;
  };
  const primary = () => {
    const token = peek();
    if (token === '(') {
      eat('(');
      const inner = expr();
      eat(')');
      return inner;
    }
    if (token === '-') { eat('-'); return -primary(); }
    if (token !== undefined && /^\d/.test(token)) { at += 1; return Number(token); }
    throw new SyntaxError(`unexpected ${token ?? 'end of input'}`);
  };
  const term = () => {
    let left = primary();
    while (peek() === '*' || peek() === '/') {
      const op = peek();
      at += 1;
      const right = primary();
      left = op === '*' ? left * right : left / right;
    }
    return left;
  };
  const expr = () => {
    let left = term();
    while (peek() === '+' || peek() === '-') {
      const op = peek();
      at += 1;
      const right = term();
      left = op === '+' ? left + right : left - right;
    }
    return left;
  };
  const value = expr();
  if (at !== tokens.length) throw new SyntaxError(`trailing ${peek()}`);
  return value;
};

const complain = (text) => {
  try { return evaluate(text); } catch (e) { return e.message; }
};

console.log('tokens :', tokenise('1500 * (12 - 2)'));
console.log('fare   :', evaluate('1500 * 12 + 500'));
console.log('errors :', ['(2 + 3', '2 +', '2 3', '4 % 2'].map(complain));

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('multiplication binds tighter than addition', evaluate('2 + 3 * 4'), 14);
test('brackets override that, because primary re-enters expr', evaluate('(2 + 3) * 4'), 20);
test('subtraction is left associative, which the loop gives for free', evaluate('10 - 3 - 2'), 5);
test('unary minus works in front of a number and of a bracket', [evaluate('-4 + 10'), evaluate('3 * -(1 + 1)')], [6, -6]);
test('decimals and division come out as ordinary numbers', evaluate('7 / 2'), 3.5);
test('so a fare of 12km at 1500 plus a 500 pickup is one expression', evaluate('1500 * 12 + 500'), 18500);
test('an unclosed bracket names the token it wanted', complain('(2 + 3'), 'expected ), found end of input');
test('an operator with nothing after it is caught in primary', complain('2 +'), 'unexpected end of input');
test('and input that parsed but did not finish is rejected, not silently truncated', [complain('2 3'), complain('4 % 2')], ['trailing 3', 'trailing %']);

Recursive descent is the parsing technique worth knowing by hand, because the grammar and the code are the same shape. One function per precedence level, each looping over its own operators and calling the tighter level for its operands. Left associativity falls out of the loop; had expr called itself recursively for the right side instead, 10 - 3 - 2 would come out as 9.

The tokeniser's last alternative, \S, is the one that earns its place. Without it a stray % would simply vanish and 4 % 2 would quietly evaluate to 4. Making unknown characters into tokens means the parser sees them and can complain.

The final check — that at reached the end — is the difference between a parser and a prefix-reader. expr is perfectly happy to stop early, so without that line '2 3' returns 2 and a typo becomes a wrong number rather than an error. Note also what this evaluator deliberately does not do: no eval, no new Function, so a string from a user cannot run code.

123 a tagged template that escapes what it interpolates

hard

Write an html tag function that escapes every interpolated value, so a name containing <script> renders as text. Let a nested html result and an array of them pass through unescaped by returning a small wrapper type rather than a string. Render null and undefined as nothing, and show what strings.raw holds.

Stuck? Three hints, smallest first
  1. 1. A tag function is called with the literal chunks first and the values after: (strings, ...values), where strings.length is always one more than values.length.
  2. 2. Escape all five characters in one replace with a callback. Chaining five replaces re-escapes the ampersands you just wrote.
  3. 3. To know that a value is already safe, it has to be a different type from a string — hence the wrapper class, and hence the need for String(...) at the end.

The answer

const ENTITIES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (ch) => ENTITIES[ch]);

class Safe {
  constructor(markup) { this.markup = markup; }
  toString() { return this.markup; }
}

const render = (value) =>
  value instanceof Safe ? value.markup
    : Array.isArray(value) ? value.map(render).join('')
      : value === null || value === undefined ? ''
        : escapeHtml(value);

const html = (strings, ...values) =>
  new Safe(strings.reduce((out, chunk, i) => out + render(values[i - 1]) + chunk));

const shapes = (strings, ...values) => ({ chunks: strings.length, values: values.length, cooked: strings[1], raw: strings.raw[1] });

const vendor = { name: 'Ade & Sons', review: '<script>steal()</script>', stall: null, crates: 12 };
const card = html`<p>${vendor.name} sold ${vendor.crates} crates</p>`;
const nasty = html`<blockquote>${vendor.review}</blockquote>`;
const nested = html`<ul>${['Yaba', 'Surulere'].map((z) => html`<li>${z}</li>`)}</ul>`;
const missing = html`<span>${vendor.stall}${undefined}</span>`;
const parts = shapes`a${1}b\nc${2}d`;

console.log('card  :', String(card));
console.log('nasty :', String(nasty));
console.log('nested:', String(nested));

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('an interpolated ampersand becomes an entity', String(card), '<p>Ade &amp; Sons sold 12 crates</p>');
test('a script tag arrives as text, so the browser will not run it', String(nasty), '<blockquote>&lt;script&gt;steal()&lt;/script&gt;</blockquote>');
test('the markup you typed yourself is untouched, only the holes are escaped', String(card).startsWith('<p>'), true);
test('a nested html result is trusted, and an array of them joins', String(nested), '<ul><li>Yaba</li><li>Surulere</li></ul>');
test('null and undefined render as nothing at all', String(missing), '<span></span>');
test('the tag gets one more chunk than it gets values', [parts.chunks, parts.values], [3, 2]);
test('the cooked chunk has a real newline, the raw one has a backslash and an n', [parts.cooked.length, parts.raw.length], [3, 4]);
test('the result is an object, not a string, which is the price of knowing it is safe', typeof card, 'object');
test('and escaping in one pass is what stops &amp;lt; becoming &amp;amp;lt;', escapeHtml('a<b&c'), 'a&lt;b&amp;c');

The reason to reach for a tag function is that it can tell the difference between the parts you wrote and the parts that came from data. The literal chunks arrive as strings and are trusted; everything in values came from somewhere else and is escaped. That is the whole security model, and it is why concatenating markup with + can never be made safe by the same means.

Two details make it hold up. Escaping in a single replace with a callback avoids the classic double-escape, where you turn < into &lt; and then turn that ampersand into &amp;. And trust has to be carried by a type: if html returned a plain string there would be no way to tell an already-escaped fragment from an attacker's string, so nesting would either double-escape or open a hole. The cost is real, and the eighth assertion is it — the result is an object, and forgetting String(...) somewhere will show a reader [object Object].

strings.raw is the same chunks before escape processing. String.raw is the tag built on it, and it is what you want for a Windows path or a regular expression written inside a template.

124 diff two objects, then apply the diff

hard

Compare two versions of a record and report { added, removed, changed }, where changed holds [before, after] pairs. Compare values deeply, so a nested object with identical contents counts as unchanged even though it is a different object. Then write apply and show that the diff really does turn the old record into the new one.

Stuck? Three hints, smallest first
  1. 1. The keys to consider are the union of both key lists: [...new Set([...Object.keys(a), ...Object.keys(b)])].
  2. 2. Use Object.hasOwn, not a truthy check, or a field explicitly set to undefined looks like a missing one.
  3. 3. Deep equality is four lines of recursion: identical, then reject non-objects, then compare key counts, then every.

The answer

const deepEqual = (a, b) => {
  if (a === b) return true;
  if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
  const ak = Object.keys(a);
  const bk = Object.keys(b);
  return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k]));
};

const diff = (before, after) => {
  const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])];
  return keys.reduce((acc, key) => {
    const had = Object.hasOwn(before, key);
    const has = Object.hasOwn(after, key);
    if (!had) return { ...acc, added: { ...acc.added, [key]: after[key] } };
    if (!has) return { ...acc, removed: { ...acc.removed, [key]: before[key] } };
    if (deepEqual(before[key], after[key])) return acc;
    return { ...acc, changed: { ...acc.changed, [key]: [before[key], after[key]] } };
  }, { added: {}, removed: {}, changed: {} });
};

const apply = (base, d) => {
  const next = { ...base, ...d.added };
  for (const key of Object.keys(d.removed)) delete next[key];
  for (const [key, [, to]] of Object.entries(d.changed)) next[key] = to;
  return next;
};

const before = { name: 'Mama Nkechi', lga: 'Ikeja', phone: '08031234567', stall: { row: 'B', number: 12 }, tags: ['veg'] };
const after = { name: 'Mama Nkechi', lga: 'Surulere', stall: { row: 'B', number: 12 }, tags: ['veg', 'fruit'], since: 2019 };

const d = diff(before, after);
const nothing = diff(before, before);
const blanked = diff({ a: 1 }, { a: undefined });
const nanDiff = diff({ takings: NaN }, { takings: NaN });

console.log('changed:', d.changed);
console.log('added  :', d.added, '| removed:', d.removed);
console.log('applied:', apply(before, d));

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('only the fields that really differ are reported', Object.keys(d.changed), ['lga', 'tags']);
test('with both sides, so the report is enough for an audit line', d.changed.lga, ['Ikeja', 'Surulere']);
test('a key only one side has is added or removed, not changed', [d.added, d.removed], [{ since: 2019 }, { phone: '08031234567' }]);
test('two different objects with the same contents count as unchanged', [before.stall === after.stall, Object.hasOwn(d.changed, 'stall')], [false, false]);
test('a record against itself produces an empty diff', [Object.keys(nothing.added).length, Object.keys(nothing.changed).length, Object.keys(nothing.removed).length], [0, 0, 0]);
test('setting a field to undefined is a change, not a removal', [Object.keys(blanked.changed), Object.keys(blanked.removed)], [['a'], []]);
test('NaN never equals itself, so a NaN field looks changed every time', [Object.keys(nanDiff.changed), nanDiff.changed.takings.every(Number.isNaN)], [['takings'], true]);
test('applying the diff to the old record reconstructs the new one', deepEqual(apply(before, d), after), true);
test('and this equality ignores key order, where a stringify comparison does not', [deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }), same({ a: 1, b: 2 }, { b: 2, a: 1 })], [true, false]);

A diff is the honest way to log a change. "Vendor updated" tells nobody anything; lga: Ikeja → Surulere is an audit trail, an undo, and a patch to send over the wire. Keeping both sides in changed is what makes all three possible from one structure.

Deep comparison is the part people get wrong by reaching for JSON.stringify(a) === JSON.stringify(b). The last assertion shows why: stringify is sensitive to key order, so two objects carrying identical information compare as different, and your diff reports a change that did not happen. It also drops undefined, turns a Date into a string and throws on a cycle. Four lines of recursion avoid all of that.

Two edges are worth remembering. Object.hasOwn rather than a truthy or undefined check is what separates "the field is gone" from "the field is present and empty", which are different events in a form. And NaN !== NaN, so any numeric field that can be NaN will appear in every diff for ever unless the leaf comparison uses Object.is.

125 flatten nested keys to dotted paths, and back

hard

Turn a nested settings object into one flat object keyed by dotted path — shop.address.lga, delivery.zones.1 — and write the inverse. The inverse has to decide at each step whether to create an object or an array, and the only clue is whether the next segment is all digits. Then find the input the scheme cannot represent.

Stuck? Three hints, smallest first
  1. 1. Recursion with an accumulated prefix: at each entry, the path is head ? head + "." + key : key.
  2. 2. An empty object or array has no entries, so a plain recursion drops it. Handle it as its own case or it disappears from the round trip.
  3. 3. When rebuilding, look ahead: if the next segment matches /^\d+$/ the container you are about to create should be an array.

The answer

const isContainer = (v) => v !== null && typeof v === 'object';

const flatten = (value, head = '') => {
  if (!isContainer(value)) return { [head]: value };
  const entries = Object.entries(value);
  if (entries.length === 0) return head ? { [head]: Array.isArray(value) ? [] : {} } : {};
  return entries.reduce((acc, [key, inner]) => {
    const path = head ? `${head}.${key}` : key;
    return { ...acc, ...flatten(inner, path) };
  }, {});
};

const unflatten = (flat) =>
  Object.entries(flat).reduce((root, [path, value]) => {
    const keys = path.split('.');
    const last = keys.pop();
    const parent = keys.reduce((node, key, i) => {
      const nextSegment = keys[i + 1] ?? last;
      if (node[key] === undefined) node[key] = /^\d+$/.test(nextSegment) ? [] : {};
      return node[key];
    }, root);
    parent[last] = value;
    return root;
  }, {});

const settings = {
  shop: { name: 'Balogun Stores', address: { lga: 'Lagos Island', street: 'Broad Street' } },
  delivery: { zones: ['Yaba', 'Surulere'], freeAbove: 500000 },
  open: true,
};

const flat = flatten(settings);
const back = unflatten(flat);
const empties = flatten({ a: {}, b: [] });
const collided = flatten({ 'shop.name': 'typed with a dot', shop: { name: 'nested' } });
const deepArray = unflatten({ 'a.0.b': 1 });

console.log('flat  :', Object.keys(flat));
console.log('back  :', back);
console.log('edges :', empties, collided, deepArray);

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('every leaf becomes one dotted key', Object.keys(flat), ['shop.name', 'shop.address.lga', 'shop.address.street', 'delivery.zones.0', 'delivery.zones.1', 'delivery.freeAbove', 'open']);
test('so a value three levels down is one lookup away', flat['shop.address.lga'], 'Lagos Island');
test('an array index is just another segment', flat['delivery.zones.1'], 'Surulere');
test('the round trip rebuilds the original, arrays as arrays', [same(back, settings), Array.isArray(back.delivery.zones)], [true, true]);
test('an empty object or array survives only because it is a special case', empties, { a: {}, b: [] });
test('and it round trips too', same(unflatten(empties), { a: {}, b: [] }), true);
test('a numeric segment makes an array, a name makes an object', [Array.isArray(deepArray.a), deepArray.a[0].b], [true, 1]);
test('a key that already contains a dot collides with a nested one', [Object.keys(collided).length, collided['shop.name']], [1, 'nested']);
test('and a bare value has no path at all, so it lands under the empty key', flatten(5), { '': 5 });

Flattening is how nested configuration meets flat storage: environment variables, form field names, a translation file, a spreadsheet column. The recursion is small because each level only has to know its own prefix, and Object.entries treats an array as keys '0', '1' — which is exactly what makes indices work with no extra code.

The inverse cannot be as clean, because a.0 is ambiguous: an array at index zero, or an object with the key '0'. Looking ahead at the next segment and creating an array when it is all digits is the usual guess, and it is a guess — an object whose keys really are '0' and '1' comes back as an array. The empty container is the other loss: no entries means no leaves, so { a: {} } vanishes unless you write the case for it.

The last two assertions are the honest limits. A dot is a legal character in a key, so { 'shop.name': x } and { shop: { name: y } } flatten to the same path and one silently wins. If your keys can contain dots, the scheme needs an escape or a different separator, and [before, after] arrays of segments beat strings.

126 deepFreeze, and everything a shallow freeze misses

fair

Show that Object.freeze protects one level only, by mutating a nested value inside a frozen object. Then write deepFreeze that recurses, prove a nested write and an array push both throw, and find the two things it still cannot protect: a Map's contents, and its own behaviour on a cyclic object.

Stuck? Three hints, smallest first
  1. 1. Recurse over Object.values(value), and stop at anything that is not an object.
  2. 2. Freeze the object before recursing into it, and bail out early on something already frozen — that is what keeps a cycle from looping for ever.
  3. 3. A Map keeps its entries in internal slots, not in properties, so there is nothing there for freeze to reach.

The answer

const deepFreeze = (value) => {
  if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value;
  Object.freeze(value);
  Object.values(value).forEach((inner) => deepFreeze(inner));
  return value;
};

const shallow = Object.freeze({ shop: 'Balogun Stores', tariffs: { lekki: 250000 } });
let shallowTop = 'no error';
try { shallow.shop = 'somewhere else'; } catch (e) { shallowTop = e.constructor.name; }
shallow.tariffs.lekki = 1;
const leaked = shallow.tariffs.lekki;

const deep = deepFreeze({ shop: 'Balogun Stores', tariffs: { lekki: 250000 }, zones: ['Yaba'] });
let deepNested = 'no error';
try { deep.tariffs.lekki = 1; } catch (e) { deepNested = e.constructor.name; }
let pushed = 'no error';
try { deep.zones.push('Ikeja'); } catch (e) { pushed = e.constructor.name; }

const withMap = deepFreeze({ seen: new Map([['Yaba', 1]]) });
withMap.seen.set('Ketu', 2);

const knot = { name: 'knot' };
knot.self = knot;
deepFreeze(knot);

console.log('shallow :', shallowTop, '| nested value is now', leaked);
console.log('deep    :', deepNested, pushed, '| tariff still', deep.tariffs.lekki);
console.log('map     :', [...withMap.seen], '| cycle frozen:', Object.isFrozen(knot));

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('freezing stops a write to the object itself', shallowTop, 'TypeError');
test('but the object one level down was never frozen, so this succeeded', leaked, 1);
test('deepFreeze reaches it, and the write throws under strict mode', deepNested, 'TypeError');
test('a push into a frozen array throws too, since it cannot be extended', pushed, 'TypeError');
test('frozen all the way down', [Object.isFrozen(deep), Object.isFrozen(deep.tariffs), Object.isFrozen(deep.zones)], [true, true, true]);
test('and the values survived the attempts unchanged', [deep.tariffs.lekki, deep.zones], [250000, ['Yaba']]);
test('a Map keeps its entries out of reach of freeze, so it still grew', [...withMap.seen], [['Yaba', 1], ['Ketu', 2]]);
test('freezing before recursing is what lets a cycle terminate', [Object.isFrozen(knot), knot.self === knot], [true, true]);
test('primitives need no freezing: they already report as frozen', [deepFreeze(7), Object.isFrozen('Lagos')], [7, true]);

The one-line summary: Object.freeze is shallow, and almost every use of it in real code wants to be deep. A frozen configuration object whose nested tariff table can still be rewritten gives you the comfort of immutability with none of the guarantee, and the second assertion above is that mutation succeeding in silence.

Two details in the recursion matter. Freezing before descending, plus the isFrozen early return, is what makes a cyclic structure terminate — do it the other way round and knot.self recurses until the stack ends. And the recursion has to stop at non-objects, since Object.freeze on a primitive is a no-op that returns it unchanged.

What it cannot do is worth knowing before you rely on it. A Map, Set, Date or class instance with private fields keeps its state in internal slots, so freezing the wrapper leaves the contents wide open. Getters still run. And under sloppy mode all these failures are silent — it is "use strict" that turns them into the TypeErrors asserted here, which is the only reason you would ever find out.

127 iterator helpers, written by hand

hard

Write map, filter and take as generators over any iterable, plus a pipeline that threads a source through them. Pull four values out of an endless counter, prove that the mapping function ran only as many times as values were needed, that a consumed pipeline is empty the second time, and that stopping early still runs a source's finally.

Stuck? Three hints, smallest first
  1. 1. Each helper is three lines: for (const v of source) yield .... Because it is a generator, nothing runs until someone pulls.
  2. 2. take is the only one that needs a counter, and it must return rather than break so the count is checked after the yield.
  3. 3. Never spread an endless generator. Something in the chain has to be a take, or the loop guard is your only protection — and for...of is not guarded at all.

The answer

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

function* map(source, fn) {
  for (const value of source) yield fn(value);
}

function* filter(source, ok) {
  for (const value of source) if (ok(value)) yield value;
}

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

const toArray = (source) => [...source];
const pipeline = (source, ...steps) => steps.reduce((acc, step) => step(acc), source);

const everyThirdInKobo = toArray(pipeline(
  naturals(),
  (s) => filter(s, (n) => n % 3 === 0),
  (s) => map(s, (n) => n * 100),
  (s) => take(s, 4),
));

let squareCalls = 0;
const threeSquares = toArray(take(map(naturals(), (n) => { squareCalls += 1; return n * n; }), 3));

const once = take(naturals(), 3);
const firstDrain = toArray(once);
const secondDrain = toArray(once);

let closed = false;
function* readings() {
  try {
    yield 1;
    yield 2;
    yield 3;
  } finally {
    closed = true;
  }
}
const twoOfThree = toArray(take(readings(), 2));

console.log('pipeline :', everyThirdInKobo);
console.log('lazy     :', threeSquares, '| the mapper ran', squareCalls, 'times');
console.log('drains   :', firstDrain, secondDrain, '| source closed:', 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('four multiples of three, in kobo, out of an endless counter', everyThirdInKobo, [300, 600, 900, 1200]);
test('the mapper ran once per value taken, not once per value available', [threeSquares, squareCalls], [[1, 4, 9], 3]);
test('take past the end of a finite source just stops', toArray(take(['Yaba', 'Ketu'], 5)), ['Yaba', 'Ketu']);
test('taking none pulls nothing at all', toArray(take(naturals(), 0)), []);
test('the helpers accept any iterable, a string or a Set included', [toArray(take('Lagos', 3)), toArray(map(new Set([1, 2, 2, 3]), (n) => n * 2))], [['L', 'a', 'g'], [2, 4, 6]]);
test('a generator is a one-shot: the second drain finds it empty', [firstDrain, secondDrain], [[1, 2, 3], []]);
test('stopping early still gives the source its cleanup', [twoOfThree, closed], [[1, 2], true]);
test('and each helper hands back an iterator, not an array', [Array.isArray(map([1], (n) => n)), typeof map([1], (n) => n)[Symbol.iterator]], [false, 'function']);

Three generators and a reduce give you a lazy pipeline. The shape to notice is that none of the helpers has a loop that drives anything — for (const v of source) yield pulls exactly one value per value requested, so the consumer at the end of the chain sets the pace. That is what makes an endless source usable at all.

take is where the care goes. Counting after the yield and returning means the count is checked when the consumer comes back for more, so take(s, 3) pulls three values and no fourth. And return inside a for...of is not just an exit: it calls the source iterator's return(), which resumes the source generator with a return completion and runs its finally. That is how a lazy read of a file or a cursor gets closed when you stop halfway, and it is the assertion above about closed.

The one-shot assertion is the trap people meet in practice. A generator is an iterator, so it is its own Symbol.iterator and draining it consumes it: pass a pipeline to two functions and the second gets nothing. Arrays are re-iterable; iterators are not. If you need two passes, materialise with toArray or build the pipeline twice from a factory. JavaScript now ships Iterator.prototype.map, filter, take and drop that do all this natively, with exactly these semantics.

128 zip, and why a lazy chain touches fewer elements

hard

Write zip over two iterables, stopping at the shorter one. Then run the same three-step job twice — uppercase, keep the long names, take two — once as an array chain and once as a generator chain, counting how many times the first step runs each way. Finally show the value zip quietly swallows from the longer side.

Stuck? Three hints, smallest first
  1. 1. Take the iterators yourself with [Symbol.iterator]() and step them together; for...of can only drive one.
  2. 2. Count with a side effect in the mapping function. The array version maps the whole array before filter ever sees a value.
  3. 3. When one side is done, zip has already pulled from the other — that value has nowhere to go.

The answer

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

function* map(source, fn) {
  for (const value of source) yield fn(value);
}

function* filter(source, ok) {
  for (const value of source) if (ok(value)) yield value;
}

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

function* zip(left, right) {
  const a = left[Symbol.iterator]();
  const b = right[Symbol.iterator]();
  while (true) {
    const x = a.next();
    const y = b.next();
    if (x.done || y.done) return;
    yield [x.value, y.value];
  }
}

const toArray = (source) => [...source];
const stops = ['Oshodi', 'Ikeja', 'Yaba', 'Surulere', 'Ojota', 'Ketu', 'Mile 12', 'Berger'];

let eagerTouches = 0;
const eager = stops
  .map((s) => { eagerTouches += 1; return s.toUpperCase(); })
  .filter((s) => s.length > 4)
  .slice(0, 2);

let lazyTouches = 0;
const lazy = toArray(take(
  filter(map(stops, (s) => { lazyTouches += 1; return s.toUpperCase(); }), (s) => s.length > 4),
  2,
));

const numbered = toArray(map(zip(naturals(), stops), ([i, name]) => `${i}. ${name}`));
const uneven = toArray(zip([1, 2, 3], ['a', 'b']));
const counter = naturals();
const shortPairs = toArray(zip(counter, ['a', 'b']));
const nextFromCounter = counter.next().value;

console.log('eager :', eager, '| touched', eagerTouches);
console.log('lazy  :', lazy, '| touched', lazyTouches);
console.log('zip   :', numbered.slice(0, 3), '| uneven:', uneven);

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('both chains give the same answer', [eager, lazy], [['OSHODI', 'IKEJA'], ['OSHODI', 'IKEJA']]);
test('the array chain maps all eight; the generator chain maps two', [eagerTouches, lazyTouches], [8, 2]);
test('zip numbers a finite list against an endless counter', numbered.slice(0, 3), ['1. Oshodi', '2. Ikeja', '3. Yaba']);
test('and stops when the finite side runs out', numbered.length, 8);
test('with unequal lengths it stops at the shorter one', uneven, [[1, 'a'], [2, 'b']]);
test('but it had already pulled a third value from the longer side', [shortPairs.length, nextFromCounter], [2, 4]);
test('an endless source is harmless as long as something bounds it', toArray(take(naturals(9), 3)), [9, 10, 11]);
test('the array chain builds two throwaway arrays; the lazy one builds none', [Array.isArray(stops.map((s) => s)), Array.isArray(map(stops, (s) => s))], [true, false]);

The count is the lesson. map().filter().slice() does every step to every element before the next step starts, so eight names are uppercased to keep two, and two intermediate arrays are allocated on the way. The generator chain runs the steps per element and stops as soon as take is satisfied: two names touched, no intermediate arrays. On eight strings this is invisible; on a hundred thousand rows where the first step is expensive, it is the difference between the two designs.

Laziness is not free. Generators have per-value overhead, so for small arrays where you want everything anyway, the array chain is usually faster as well as clearer. Reach for the lazy version when the source is large, endless, or expensive per element, or when you genuinely need only the first few results.

zip hides a real cost: to discover that one side is finished it has to ask, and by then it has already taken a value from the other side and has nothing to pair it with. The assertion catches it — the counter is sitting on 4 after only two pairs came out. If the other side is a network cursor or a queue, that value is gone. Zipping with a lookahead, or checking the shorter side first, is the fix when it matters.

129 a trampoline for recursion too deep for the stack

hard

Show that a tail-recursive sum blows the stack at two hundred thousand, then convert it: instead of calling itself, each step returns a description of the next call, and a small loop keeps bouncing until it gets a result. Do the same for a pair of mutually recursive functions, which no amount of rewriting into a single loop would fix as neatly.

Stuck? Three hints, smallest first
  1. 1. Two makers is all the vocabulary you need: more(fn, ...args) and done(value), both plain objects.
  2. 2. The trampoline is a while loop: as long as it holds a more, call it and keep what comes back.
  3. 3. The recursive function no longer recurses — return more(self, n - 1, acc + n) ends the frame before the next step begins.

The answer

const more = (fn, ...args) => ({ tag: 'more', fn, args });
const done = (value) => ({ tag: 'done', value });

const trampoline = (start) => {
  let step = start;
  while (step.tag === 'more') step = step.fn(...step.args);
  return step.value;
};

const sumTo = (n, acc = 0) => (n === 0 ? acc : sumTo(n - 1, acc + n));
let blewUp = 'no error';
try { sumTo(200000); } catch (e) { blewUp = e.constructor.name; }

const sumStep = (n, acc = 0) => (n === 0 ? done(acc) : more(sumStep, n - 1, acc + n));
const bounced = trampoline(sumStep(200000));

let plain = 0;
for (let i = 1; i <= 200000; i++) plain += i;

let bounces = 0;
const counted = (n, acc = 0) => {
  bounces += 1;
  return n === 0 ? done(acc) : more(counted, n - 1, acc + n);
};
const smallSum = trampoline(counted(5));

const isEven = (n) => (n === 0 ? done(true) : more(isOdd, n - 1));
const isOdd = (n) => (n === 0 ? done(false) : more(isEven, n - 1));
const parity = [trampoline(isEven(100001)), trampoline(isEven(4))];

const firstStep = sumStep(3);

console.log('direct    :', blewUp);
console.log('trampoline:', bounced, '| plain loop:', plain);
console.log('parity    :', parity, '| bounces for 5:', bounces);

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('two hundred thousand frames is more than the stack holds', blewUp, 'RangeError');
test('the trampoline gets the answer anyway', bounced, 20000100000);
test('and it agrees with the loop nobody needed a trick for', bounced === plain, true);
test('the small case is ordinary arithmetic', smallSum, 15);
test('one bounce per step, plus the one that returns done', bounces, 6);
test('mutual recursion trampolines just as well', parity, [false, true]);
test('a done value passes straight through with no bouncing', trampoline(done(9)), 9);
test('shallow recursion still works directly, so this is a choice not a rule', sumTo(1000) === trampoline(sumStep(1000)), true);
test('and each step is a plain object you could log or queue', { tag: firstStep.tag, args: firstStep.args, fn: typeof firstStep.fn }, { tag: 'more', args: [2, 3], fn: 'function' });

JavaScript engines do not eliminate tail calls — the specification asks for it, and no major engine implements it — so a tail-recursive function is exactly as stack-hungry as any other. The stack is somewhere around ten thousand frames deep, which is plenty until you meet a long list, a deep tree, or a state machine that steps once per input character.

The trampoline swaps the stack for a loop. Each step returns before the next begins, so the depth is always one, and the "recursion" lives in the while. The price is a small object per step and a function that no longer reads like the algorithm — which is why the eighth assertion is there: for a thousand items, plain recursion is clearer and works fine.

Where it genuinely earns its place is mutual recursion, and anything where the next step is chosen at run time. isEven and isOdd calling each other cannot be turned into one obvious loop, but as trampolined steps they need no rewriting at all. The same shape underlies interpreters and schedulers: a step is data — { tag, fn, args } — so you can log it, count it, pause between bounces, or hand it to a queue.

130 pipe and compose, with async steps

hard

Write pipeAsync(...steps) that threads a value through steps which may be async or plain, by reducing over then. Build a vendor lookup from four small steps, keep a trail of which ones ran, and prove that a rejection in the middle stops the rest. Then write composeAsync and show it is the same pipeline written backwards.

Stuck? Three hints, smallest first
  1. 1. Start from Promise.resolve(input) and reduce: (acc, step) => acc.then(step). That is the whole implementation.
  2. 2. then accepts a function that returns a plain value, so a synchronous step needs no wrapping and no await.
  3. 3. composeAsync is pipeAsync with the list reversed. Reverse a copy: reverse mutates.

The answer

const pipeAsync = (...steps) => (input) =>
  steps.reduce((acc, step) => acc.then(step), Promise.resolve(input));
const composeAsync = (...steps) => pipeAsync(...[...steps].reverse());

const DIRECTORY = { 'mama nkechi': { lga: 'Ikeja', kobo: 50000 } };
const trail = [];
const step = (name, fn) => async (value) => {
  trail.push(name);
  return fn(value);
};

const normalise = step('normalise', (s) => s.trim().toLowerCase());
const lookup = step('lookup', (slug) => DIRECTORY[slug] ?? null);
const required = step('required', (row) => {
  if (!row) throw new Error('vendor not found');
  return row;
});
const priced = step('priced', (row) => ({ ...row, naira: row.kobo / 100 }));

const findVendor = pipeAsync(normalise, lookup, required, priced);

const found = await findVendor('  Mama Nkechi ');
const trailWhenFound = [...trail];
trail.length = 0;

let failure = 'no error';
try { await findVendor('Unknown Trader'); } catch (e) { failure = e.message; }
const trailWhenLost = [...trail];
trail.length = 0;

const backwards = composeAsync(priced, required, lookup, normalise);
const alsoFound = await backwards('MAMA NKECHI');
trail.length = 0;

const inc = async (n) => n + 1;
const dbl = async (n) => n * 2;
const bothWays = [await pipeAsync(inc, dbl)(5), await composeAsync(inc, dbl)(5)];
const mixed = await pipeAsync((n) => n + 1, async (n) => n * 3)(2);
const emptyPipe = pipeAsync()(3);

console.log('found  :', found);
console.log('trails :', trailWhenFound, trailWhenLost);
console.log('ways   :', bothWays, '| mixed sync and async:', mixed);

Output


      
8 checks that prove this answer

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

8 checks

test('the pipeline resolves to the last step\'s value', found, { lga: 'Ikeja', kobo: 50000, naira: 500 });
test('every step ran, left to right', trailWhenFound, ['normalise', 'lookup', 'required', 'priced']);
test('a throw inside a step becomes a rejection of the whole pipeline', failure, 'vendor not found');
test('and the steps after it never ran', trailWhenLost, ['normalise', 'lookup', 'required']);
test('compose is the same steps written right to left', alsoFound, found);
test('so the order genuinely matters, and the two disagree', bothWays, [12, 11]);
test('a plain function mixes in with no wrapping, because then accepts one', mixed, 9);
test('and a pipeline of nothing still hands back a promise', [typeof emptyPipe.then, await emptyPipe], ['function', 3]);

reduce over then is the smallest async pipeline there is, and it gets three things right for free. A synchronous step needs no special case, because then already accepts a function returning a plain value. A thrown error anywhere becomes one rejection of the whole chain, so there is a single place to catch. And starting from Promise.resolve(input) means the return type is always a promise, even for zero steps — a caller never has to check.

The trail is the part worth keeping in a real system. Short-circuiting is the correct behaviour, but it is also invisible: something failed and four later steps silently did not happen. Recording which steps ran is the difference between a log line you can act on and "vendor not found" with no idea how far it got.

The direction assertion is not pedantry. pipe(inc, dbl) and compose(inc, dbl) read almost the same and differ by one reversal, and mixing them up produces a plausible wrong number rather than an error. Pick one convention per codebase; pipe tends to win because it reads in the order things happen. Note this chain is strictly sequential — each step waits for the last. Steps that are independent want Promise.all, not a pipe.

131 error subclasses, and the cause chain

fair

Build a small hierarchy — AppError, then ValidationError and PaymentError under it — where each subclass reports its own name and carries its own extra field. Wrap a low-level failure in a PaymentError using { cause }, walk the chain from the outside in, and find out what JSON.stringify keeps of an error.

Stuck? Three hints, smallest first
  1. 1. this.name = new.target.name in the base constructor gives every subclass the right name without repeating it.
  2. 2. The second argument to Error is an options object: super(message, { cause }). Forget to forward it and the cause is gone.
  3. 3. To walk the chain, follow error.cause until it is undefined.

The answer

class AppError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = new.target.name;
  }
}

class ValidationError extends AppError {
  constructor(field, message, options) {
    super(message, options);
    this.field = field;
  }
}

class PaymentError extends AppError {
  constructor(reference, message, options) {
    super(message, options);
    this.reference = reference;
  }
}

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

const chargeCard = () => { throw new RangeError('gateway timed out after 30s'); };

const takePayment = (reference) => {
  try {
    return chargeCard();
  } catch (cause) {
    throw new PaymentError(reference, 'could not charge the card', { cause });
  }
};

const chainOf = (error) => {
  const names = [];
  let at = error;
  while (at) {
    names.push(`${at.name}: ${at.message}`);
    at = at.cause;
  }
  return names;
};

const toPlain = (error) => ({
  name: error.name,
  message: error.message,
  ...(error.cause ? { cause: toPlain(error.cause) } : {}),
});

let caught = new Error('never thrown');
try { takePayment('TX-2019'); } catch (e) { caught = e; }
const kinds = [caught instanceof PaymentError, caught instanceof AppError, caught instanceof Error];
const sibling = new ValidationError('phone', 'phone must be 11 digits');
const careless = new Careless('lost the trail', { cause: new Error('the real problem') });
const serialised = JSON.stringify(caught);

console.log('caught :', caught.name, '|', caught.message);
console.log('chain  :', chainOf(caught));
console.log('json   :', serialised);

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('the subclass is recognised at every level of the hierarchy', kinds, [true, true, true]);
test('and reports its own name without each class repeating it', [caught.name, sibling.name], ['PaymentError', 'ValidationError']);
test('each subclass keeps the field that makes it useful', [caught.reference, sibling.field], ['TX-2019', 'phone']);
test('cause holds the original failure, untouched', [caught.cause.name, caught.cause.message], ['RangeError', 'gateway timed out after 30s']);
test('so the chain reads from the outside in', chainOf(caught), ['PaymentError: could not charge the card', 'RangeError: gateway timed out after 30s']);
test('a sibling subclass is not the same type', sibling instanceof PaymentError, false);
test('stringify keeps only the enumerable properties: message and stack vanish', serialised, '{"name":"PaymentError","reference":"TX-2019"}');
test('so serialising needs doing by hand, and it can carry the chain', toPlain(caught), { name: 'PaymentError', message: 'could not charge the card', cause: { name: 'RangeError', message: 'gateway timed out after 30s' } });
test('a constructor that forwards only the message throws the cause away', careless.cause, undefined);

Typed errors let a caller decide by instanceof instead of matching on message text. A ValidationError becomes a 422 and a form, a PaymentError becomes a retry and a reference to quote support — and both are still Errors, so a generic handler at the top still catches them.

cause is the standard way to wrap without losing. The alternative habits both cost you something: rethrowing the original keeps the detail and loses your context, and throwing a fresh error keeps your context and loses the detail. { cause } keeps both, and the chain walk above is the log line worth printing. The catch is the last assertion: cause only arrives if the constructor forwards its options to super, and a subclass that takes (message) alone silently drops it.

new.target.name is the small trick that keeps the base class honest — every subclass gets the right name with no extra line. Note the side effect it has on serialisation: name becomes an own enumerable property, so it survives JSON.stringify while message and stack, which are non-enumerable, do not. An error crossing a network boundary needs an explicit projection like toPlain, or it arrives as a nearly empty object.

132 try, finally, and a return that hijacks the answer

fair

Show the four things finally does. It runs on the way out of a return; a return inside it replaces the value the try was going to give; that replacement also swallows an exception that was in flight; and a throw inside it replaces the outcome too. Then show that the returned expression is evaluated before finally runs, and what that means for an object.

Stuck? Three hints, smallest first
  1. 1. The value of return is computed first, then finally runs, then the function actually returns. Reassigning the variable afterwards is too late.
  2. 2. Too late for a variable, not for an object: the reference was already captured, so a property written in finally is visible to the caller.
  3. 3. A return in finally completes the function, which is why an exception on its way out simply stops travelling.

The answer

const normal = () => {
  try {
    return 'from try';
  } finally {
    const noReturnHere = true;
  }
};

const hijacked = () => {
  try {
    return 'from try';
  } finally {
    return 'from finally';
  }
};

const swallowed = () => {
  try {
    throw new Error('boom');
  } finally {
    return 'finally ate the error';
  }
};

const rethrown = () => {
  try {
    return 'never seen';
  } finally {
    throw new Error('finally threw instead');
  }
};

let counter = 0;
const evaluatedFirst = () => {
  try {
    return counter;
  } finally {
    counter = 99;
  }
};

const box = { n: 1 };
const mutatedAfter = () => {
  try {
    return box;
  } finally {
    box.n = 2;
  }
};

const order = [];
const traced = () => {
  try {
    order.push('try');
    return order.length;
  } finally {
    order.push('finally');
  }
};

const cleanup = [];
const readRow = (bad) => {
  cleanup.push('open');
  try {
    if (bad) throw new Error('bad row');
    return 'ok';
  } finally {
    cleanup.push('close');
  }
};

const captured = evaluatedFirst();
const returnedBox = mutatedAfter();
const tracedValue = traced();
const good = readRow(false);
let escaped = 'no error';
try { readRow(true); } catch (e) { escaped = e.message; }
let secondary = 'no error';
try { rethrown(); } catch (e) { secondary = e.message; }

console.log('values :', normal(), '|', hijacked(), '|', swallowed());
console.log('timing :', captured, '->', counter, '| box:', returnedBox.n);
console.log('cleanup:', cleanup, '| escaped:', escaped);

Output


      
9 checks that prove this answer

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

9 checks

test('a finally that returns nothing leaves the value alone', normal(), 'from try');
test('a return inside finally replaces it', hijacked(), 'from finally');
test('and swallows an exception that was already on its way out', swallowed(), 'finally ate the error');
test('a throw inside finally replaces the outcome just as completely', secondary, 'finally threw instead');
test('the returned expression is evaluated before finally runs', [captured, counter], [0, 99]);
test('but a property written through the returned reference is visible', returnedBox.n, 2);
test('the block runs first, then finally, then the caller gets the value', [tracedValue, order], [1, ['try', 'finally']]);
test('finally runs on the throwing path too, and the error still escapes', [good, escaped], ['ok', 'bad row']);
test('so the cleanup happened both times', cleanup, ['open', 'close', 'open', 'close']);

finally is for releasing what you took: closing a handle, clearing a spinner, decrementing a counter. It runs on every way out — normal return, early return, thrown error, even a break out of a loop — which is exactly why it is the right place for that and the wrong place for anything that produces a value.

A return or throw inside finally completes the function then and there. The consequence is the third assertion: an exception that was travelling outward simply stops, with no catch anywhere, no log, and no trace. Most linters ban it for this reason, and if you ever meet a bug where an error "disappears", a return in a finally is the first thing to look for.

The timing pair is subtle and worth internalising. return counter evaluates counter immediately, so finally setting it to 99 changes nothing the caller sees. But return box captured a reference, so writing box.n in finally does reach the caller. Same rule, two different-looking outcomes, and the difference is only what was captured.

133 Symbol.toPrimitive and Symbol.toStringTag

hard

Write a Money class with a private kobo field and a Symbol.toPrimitive that answers all three hints differently: a number for arithmetic, a formatted naira string for text, and something else again for the default. Add Symbol.toStringTag. Then work out which operator asks for which hint — + and - do not agree — and see what JSON.stringify makes of a private field.

Stuck? Three hints, smallest first
  1. 1. [Symbol.toPrimitive](hint) is called with 'number', 'string' or 'default'. Handle all three or one path returns undefined.
  2. 2. Only +, == and template-free coercion use the default hint. -, *, > and unary + all ask for a number.
  3. 3. Symbol.toStringTag changes Object.prototype.toString, nothing else — and it is ignored entirely when Symbol.toPrimitive exists.

The answer

class Money {
  #kobo;
  constructor(kobo) { this.#kobo = kobo; }
  get kobo() { return this.#kobo; }
  get [Symbol.toStringTag]() { return 'Money'; }
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.#kobo;
    if (hint === 'string') return `₦${(this.#kobo / 100).toFixed(2)}`;
    return `Money(${this.#kobo})`;
  }
}

class Tagged {
  get [Symbol.toStringTag]() { return 'Tagged'; }
}

class Bare {
  #hidden = 'invisible';
  constructor() { this.shown = 1; }
}

const fare = new Money(50000);
const tip = new Money(32000);

const asNumber = +fare;
const inText = `the fare is ${fare}`;
const plussed = fare + 0;
const minussed = fare - 0;
const difference = fare - tip;
const bigger = fare > tip;
const tagOf = Object.prototype.toString.call(fare);
const tagOfTagged = Object.prototype.toString.call(new Tagged());
const taggedInText = `${new Tagged()}`;

console.log('hints  :', asNumber, '|', inText, '|', plussed);
console.log('maths  :', minussed, difference, bigger);
console.log('tags   :', tagOf, tagOfTagged, taggedInText);

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('unary plus asks for a number', asNumber, 50000);
test('a template literal asks for a string', inText, 'the fare is ₦500.00');
test('plus asks for the default hint, so this concatenated instead of adding', plussed, 'Money(50000)0');
test('minus asks for a number, so the very same object subtracts fine', [minussed, difference], [50000, 18000]);
test('comparison asks for a number too', bigger, true);
test('String and interpolation agree, both being the string hint', inText.endsWith(String(fare)), true);
test('toStringTag only changes this one thing', [tagOf, tagOfTagged], ['[object Money]', '[object Tagged]']);
test('with no toPrimitive, interpolation falls back to that same tag', taggedInText, '[object Tagged]');
test('a private field is invisible to stringify; only a normal one survives', JSON.stringify(new Bare()), '{"shown":1}');

Symbol.toPrimitive is the single hook for every implicit conversion, and it takes precedence over both valueOf and toString. Three hints, and the useful ones are unambiguous: 'number' for arithmetic and comparison, 'string' for interpolation and String(). The default hint is the awkward one, used by +, == and little else, and it exists because + cannot know whether you meant addition or concatenation.

Which is the trap the middle assertions catch. fare + 0 and fare - 0 ask for different hints, so a class where the default returns a string will concatenate under + and subtract correctly under -. If your object is fundamentally a number, return the number for the default too and the surprise disappears. Date goes the other way and returns a string for the default, which is exactly why date1 - date2 is a duration in milliseconds while date1 + date2 is nonsense.

Symbol.toStringTag is much smaller than it looks: it changes Object.prototype.toString, which shows up in debugging output and in old type-sniffing code, and nothing else. Once Symbol.toPrimitive exists it is not consulted for coercion at all — the second-to-last assertion is the only case where it reaches interpolation, and that is because Tagged has no toPrimitive to override it.

134 inheritance with super, and a static factory that knows its subclass

hard

Build three levels — Vehicle, Danfo, Keke — where each fare calls super.fare and adds to it. Give Danfo a static factory that parses a plate string and returns new this(...), so calling it on Keke builds a Keke. Then show what happens if you touch this before super().

Stuck? Three hints, smallest first
  1. 1. super.fare(km) in a method looks up the prototype chain from where the method was defined, not from the instance.
  2. 2. In a static method, this is the class it was called on. new this(...) is what makes the factory inherit properly; new Danfo(...) would not.
  3. 3. A derived constructor has no this until super() has run. Reading or writing it before then is a ReferenceError, not undefined.

The answer

class Vehicle {
  static #built = 0;
  #plate;
  constructor(plate, seats) {
    this.#plate = plate;
    this.seats = seats;
    Vehicle.#built += 1;
  }
  static get built() { return Vehicle.#built; }
  get plate() { return this.#plate; }
  fare(km) { return 100 * km; }
  describe() { return `${this.constructor.name} ${this.#plate}`; }
}

class Danfo extends Vehicle {
  constructor(plate, seats, conductor) {
    super(plate, seats);
    this.conductor = conductor;
  }
  fare(km) { return super.fare(km) + 50 * this.seats; }
  static fromPlate(text) {
    const [plate, seats = '14'] = text.split('/');
    return new this(plate, Number(seats), 'unknown');
  }
}

class Keke extends Danfo {
  fare(km) { return super.fare(km) / 2; }
}

class Broken extends Vehicle {
  constructor(plate) {
    this.tooEarly = true;
    super(plate, 1);
  }
}

const builtBefore = Vehicle.built;
const bus = new Danfo('LSD-234-AB', 14, 'Sunday');
const keke = new Keke('KEK-11-XY', 3, 'none');
const parsed = Danfo.fromPlate('EPE-901-CD/18');
const parsedKeke = Keke.fromPlate('IKD-77-EF/3');
const defaulted = Danfo.fromPlate('ABC-123-ZZ');
const builtHere = Vehicle.built - builtBefore;

let beforeSuper = 'no error';
try { new Broken('XX-000'); } catch (e) { beforeSuper = e.constructor.name; }

console.log('fares  :', bus.fare(10), keke.fare(10));
console.log('factory:', parsed.constructor.name, parsedKeke.constructor.name, defaulted.seats);
console.log('built  :', builtHere, '| this before super:', beforeSuper);

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('each override adds to the one above it', [bus.fare(10), keke.fare(10)], [1700, 575]);
test('so Keke reaches Vehicle through Danfo, two links up', keke.fare(10) * 2 - 50 * keke.seats, 1000);
test('a static factory built with new this returns the class it was called on', [parsed.constructor.name, parsedKeke.constructor.name], ['Danfo', 'Keke']);
test('the instance is complete, and a plate with no seat count takes the default', [parsedKeke instanceof Keke, parsedKeke.conductor, defaulted.seats], [true, 'unknown', 14]);
test('instanceof holds at every level of the chain', [keke instanceof Keke, keke instanceof Danfo, keke instanceof Vehicle], [true, true, true]);
test('a base-class method reads the private field and the runtime name', keke.describe(), 'Keke KEK-11-XY');
test('five constructions, all counted by the base class', builtHere, 5);
test('touching this before super is a ReferenceError, so nothing was counted', beforeSuper, 'ReferenceError');
test('and the private field never appears as an own property', Object.keys(bus), ['seats', 'conductor']);

super.method() is not "call the parent of this object" — it looks up from the prototype where the method was written. That is why Keke.fare reaching super.fare lands in Danfo, whose own super.fare lands in Vehicle, and each level adds its part on the way back. Write the chain wrong once and you get a silently plausible number, which is why the fare assertions spell out the arithmetic.

new this(...) inside a static method is the detail that makes a factory inheritable. In a static method this is the class the call was made on, so Keke.fromPlate constructs a Keke with no extra code; hard-coding new Danfo(...) would quietly downgrade every subclass. The same reasoning is why Array.prototype.map and friends were given a species hook.

The ReferenceError is the rule people meet by accident. In a derived constructor, this does not exist until super() returns — the parent creates it. So super() goes first, always, and any argument massaging you need before it must be done with local variables or parameters. Note too that the private #plate is reachable from Vehicle's own method on a Keke instance, but never from outside and never in Object.keys.

135 mixins, by object composition and by subclass factory

hard

Build a driver object by spreading together three small feature makers that share one closed-over state object, so no this is involved anywhere. Show that a method pulled off the object still works, where a class method pulled off its instance does not. Then do the same job the other way — a mixin as a function from a base class to a subclass — and find the collision that object spreading hides.

Stuck? Three hints, smallest first
  1. 1. Each maker takes state and returns an object of functions closed over it. The composed object is just { ...a(state), ...b(state) }.
  2. 2. Because the functions never mention this, destructuring one off the object cannot break it. That is the whole advantage.
  3. 3. A class mixin is const M = (Base) => class extends Base { ... }, and they nest: class X extends M(N(Base)) {}.

The answer

const canGreet = (state) => ({
  greet: () => `Kaabo, ${state.name}`,
});

const canBill = (state) => ({
  bill: (km) => state.rate * km,
  raise: (by) => { state.rate += by; return state.rate; },
});

const canLog = (state) => ({
  log: (line) => { state.entries = [...state.entries, line]; return state.entries.length; },
  history: () => [...state.entries],
});

const makeDriver = ({ name, rate }) => {
  const state = { name, rate, entries: [] };
  return {
    ...canGreet(state),
    ...canBill(state),
    ...canLog(state),
    snapshot: () => ({ ...state }),
  };
};

const alsoGreets = () => ({ greet: () => 'a different greeting' });
const clashed = { ...canGreet({ name: 'Ada' }), ...alsoGreets() };

const Timestamped = (Base) => class extends Base {
  stamp(n) { return `${this.constructor.name}#${n}`; }
};
const Countable = (Base) => class extends Base {
  static tally(list) { return list.length; }
};
class Person {
  constructor(name) { this.name = name; }
}
class Rider extends Timestamped(Countable(Person)) {}

class ClassDriver {
  constructor(rate) { this.rate = rate; }
  bill(km) { return this.rate * km; }
}

const driver = makeDriver({ name: 'Musa Bello', rate: 15000 });
const beforeRaise = driver.bill(2);
const newRate = driver.raise(1000);
const afterRaise = driver.bill(2);
const logged = [driver.log('Yaba to Ojota'), driver.log('Ojota to Ketu')];
const { bill } = driver;
const detached = bill(3);

const loose = new ClassDriver(15000).bill;
let looseError = 'no error';
try { loose(3); } catch (e) { looseError = e.constructor.name; }

const rider = new Rider('Ngozi');

console.log('driver  :', driver.greet(), '|', driver.snapshot());
console.log('detached:', detached, '| class method detached:', looseError);
console.log('mixins  :', rider.stamp(3), Rider.tally([1, 2]), rider instanceof Person);

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('the makers compose into one object with all the features', Object.keys(driver), ['greet', 'bill', 'raise', 'log', 'history', 'snapshot']);
test('and they share one state, so a raise changes what billing sees', [beforeRaise, newRate, afterRaise], [30000, 16000, 32000]);
test('the greeting reads the same state object', driver.greet(), 'Kaabo, Musa Bello');
test('logging accumulates, and history hands back a copy', [logged, driver.history()], [[1, 2], ['Yaba to Ojota', 'Ojota to Ketu']]);
test('a method taken off the object still works: state came from a closure', detached, 48000);
test('the same move on a class method loses this under strict mode', looseError, 'TypeError');
test('spreading is last-wins, and a name collision is completely silent', clashed.greet(), 'a different greeting');
test('a class mixin stacks methods and statics onto a base', [rider.stamp(3), Rider.tally([1, 2])], ['Rider#3', 2]);
test('and the instance is still the base type, so instanceof survives', [rider instanceof Person, rider.name], [true, 'Ngozi']);

Two techniques, one goal: features assembled rather than inherited. Object composition keeps state in a closure and never mentions this, which is why the fifth and sixth assertions differ — const { bill } = driver works, while the same destructuring of a class method gives you a function whose this is undefined and a TypeError on the first property read. Anywhere a method is passed as a callback, that difference is the bug you were going to have.

The cost is real and measurable: every instance carries its own set of closures instead of sharing one prototype. For a handful of objects that is nothing; for a hundred thousand it is memory you can see. Classes pay the opposite trade — one shared prototype, plus the this fragility.

The subclass-factory mixin keeps the prototype chain, so instanceof still works and statics come along too, at the price of a longer chain and stacking order that matters. And the seventh assertion is the hazard both styles share: two mixins defining greet means the later one wins with no warning at all. Keep mixin surfaces small and names specific, or check for collisions when you compose.

136 sort names properly, with a fixed locale

fair

Sort a list of Nigerian names and show that the default sort() gets it wrong: it compares UTF-16 code units, so every capital comes before every lowercase and the Yoruba letters land past z. Fix it with localeCompare and an explicit locale, then with a reused Intl.Collator. Use sensitivity to make a search ignore case and accents, and numeric to order stall numbers.

Stuck? Three hints, smallest first
  1. 1. A comparator returns a negative number, zero or a positive number — a.localeCompare(b, locale) already is one.
  2. 2. Always pass the locale. Left out, it uses whatever the machine is set to, so the same code sorts differently on two computers.
  3. 3. { sensitivity: 'base' } treats e, é and E as equal; { numeric: true } compares runs of digits as numbers.

The answer

const names = ['Ọladele', 'Adéwálé', 'adebayo', 'Zainab', 'Chinedu', 'Ẹmeka', 'Ibrahim'];

const byCodeUnit = [...names].sort();
const byLocale = [...names].sort((a, b) => a.localeCompare(b, 'en-NG'));

const collator = new Intl.Collator('en-NG');
const byCollator = [...names].sort(collator.compare);

const loose = new Intl.Collator('en-NG', { sensitivity: 'base' });
const accentPair = [loose.compare('Adéwálé', 'Adewale'), collator.compare('Adéwálé', 'Adewale')];
const casePair = [loose.compare('adebayo', 'Adebayo'), Math.abs(Math.sign(collator.compare('adebayo', 'Adebayo')))];

const stalls = ['Stall 10', 'Stall 2', 'Stall 1'];
const byText = [...stalls].sort((a, b) => a.localeCompare(b, 'en-NG'));
const byNumber = [...stalls].sort((a, b) => a.localeCompare(b, 'en-NG', { numeric: true }));

const signs = ['adebayo'.localeCompare('Zainab', 'en-NG'), 'Zainab'.localeCompare('adebayo', 'en-NG')].map(Math.sign);
const found = names.filter((n) => loose.compare(n, 'adewale') === 0);

console.log('code units:', byCodeUnit);
console.log('locale    :', byLocale);
console.log('stalls    :', byText, '->', byNumber);

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('the default sort strands lowercase and the Yoruba letters after Z', byCodeUnit, ['Adéwálé', 'Chinedu', 'Ibrahim', 'Zainab', 'adebayo', 'Ẹmeka', 'Ọladele']);
test('localeCompare with an explicit locale puts them where a reader expects', byLocale, ['adebayo', 'Adéwálé', 'Chinedu', 'Ẹmeka', 'Ibrahim', 'Ọladele', 'Zainab']);
test('a reused Collator gives the same order, and is the faster way to sort', byCollator, byLocale);
test('at base sensitivity the accents stop mattering; by default they do', [accentPair[0], Math.abs(Math.sign(accentPair[1]))], [0, 1]);
test('and so does case, which is what you want for a search box', casePair, [0, 1]);
test('so a loose comparison finds a name typed without its diacritics', found, ['Adéwálé']);
test('as text, Stall 10 sorts before Stall 2', byText, ['Stall 1', 'Stall 10', 'Stall 2']);
test('numeric: true reads the digits as a number and fixes it', byNumber, ['Stall 1', 'Stall 2', 'Stall 10']);
test('only the sign of the result is specified, never the magnitude', signs, [-1, 1]);

Array.prototype.sort with no comparator converts to strings and compares UTF-16 code units. That is why every capital letter sorts before every lowercase one — 'Z' is 90, 'a' is 97 — and why Ẹ and Ọ, which live in the Latin Extended Additional block, end up past z entirely. For any list a person will read, it is simply the wrong comparison.

Passing the locale explicitly is the habit to build. localeCompare(b) with no locale uses the host's setting, so the same code produces different orders on your laptop and the server, and a test that passes locally fails in CI. And when you are sorting more than a handful of items, build one Intl.Collator and pass collator.compare — localeCompare constructs a collator per comparison unless the engine caches it.

sensitivity: 'base' is the setting worth remembering. It makes Adéwálé, Adewale and ADEWALE compare equal, which is what a search box needs: someone typing without diacritics still finds the record. The last assertion is the API's own small print — the result is a sign, not a distance, so compare it against zero and never against -1 or 1.

Newsletter

New writing, when it lands.

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