Emmanuel Akinfulubi

JavaScript: Fundamental (ES6), part 2, 98–118

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

Exercises 98 to 118 of 118

98 Searching names when the accents may or may not be typed

fair

A user types adeyemi and the record says Adéyemi. Make the search find it two ways: by folding the accents off both sides with normalize, and by comparing with a collator at base sensitivity. Show that the two spellings are genuinely different strings, that a plain sort puts an accented name in the wrong place, and where each approach gives up.

Stuck? Three hints, smallest first
  1. 1. Two identical-looking strings can differ in length: é is one code point precomposed and two decomposed.
  2. 2. normalize('NFD') then stripping \p{Diacritic} gives you a comparison key.
  3. 3. Intl.Collator with sensitivity: 'base' ignores case and accents without rewriting the string.

The answer

const fold = (text) => text.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
const looseIncludes = (haystack, needle) => fold(haystack).includes(fold(needle));

const base = new Intl.Collator('en', { sensitivity: 'base' });      // ignores case AND accents
const accentAware = new Intl.Collator('en', { sensitivity: 'accent' });   // ignores case only

const precomposed = 'Ad\u00e9yemi';          // e-acute as one code point
const decomposed = 'Ade\u0301yemi';          // e followed by a combining acute
const lookTheSame = precomposed === decomposed;
const lengths = [precomposed.length, decomposed.length];
const normalisedMatch = precomposed.normalize('NFD') === decomposed.normalize('NFD');

const record = 'Ọlámidé Adéyemi';
const plainSearch = record.toLowerCase().includes('olamide');
const foldedSearch = looseIncludes(record, 'olamide');
const foldedKey = fold(record);

const names = ['Zainab', 'Ébenezer', 'Adaeze'];
const defaultSort = [...names].sort();
const collatedSort = [...names].sort(base.compare);

const collatorAgrees = base.compare('adeyemi', precomposed);
const foldDoesNotStretch = [fold('Ødegaard'), fold('Stra\u00dfe')];
const collatorDoes = [base.compare('\u00f8', 'o'), base.compare('\u00df', 'ss')];
const accentSensitivity = [accentAware.compare('adeyemi', precomposed), accentAware.compare('adeyemi', 'ADEYEMI')];

console.log('same to look at, different to compare:', lookTheSame, lengths);
console.log('plain includes :', plainSearch, '| folded:', foldedSearch);
console.log('default sort   :', defaultSort);
console.log('collated sort  :', collatedSort);

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 two spellings look identical, compare as different, and differ in length', [lookTheSame, lengths, normalisedMatch], [false, [7, 8], true]);
test('a plain lower-cased includes misses the accented record', plainSearch, false);
test('folding both sides finds it, because the key has no marks left', [foldedSearch, foldedKey], [true, 'olamide adeyemi']);
test('a collator at base sensitivity agrees without rewriting anything', collatorAgrees, 0);
test('the default sort compares code units, so E-acute lands after Z', defaultSort, ['Adaeze', 'Zainab', '\u00c9benezer']);
test('the collator sorts it where a reader expects', collatedSort, ['Adaeze', '\u00c9benezer', 'Zainab']);
test('folding cannot help with letters that do not decompose', foldDoesNotStretch, ['\u00f8degaard', 'stra\u00dfe']);
test('the collator handles those, treating slashed o as o and eszett as ss', collatorDoes, [0, 0]);
test('and accent sensitivity keeps the accents apart while still ignoring case', accentSensitivity, [-1, 0]);

The first assertion is the fact everything else follows from. Two strings can render identically and still be unequal, because Unicode allows é as one code point or as e plus a combining mark. Text arriving from a macOS filename, an iOS keyboard and a Windows form can genuinely differ in normalisation form, so a database lookup, a Set of names or a === comparison will quietly disagree with the user. Normalise on the way in — NFC is the usual choice — and the problem disappears at the boundary rather than following you around.

For searching, the two tools are not interchangeable. Folding produces a key: a plain-ASCII string you can store in a column, index, and use with includes or startsWith, which is what a substring search needs. A collator produces an ordering: correct, locale-aware and much better informed — the eighth assertion shows it equating ø with o and ß with ss, which no amount of mark-stripping will do — but it compares whole strings, so it cannot answer "does this name contain what was typed".

Two warnings on the collator. It is locale-sensitive by design, and that cuts both ways: in Danish, ø is a distinct letter sorting after z, so the same call with 'da' gives a different answer, correctly. And Intl.Collator is expensive to construct and cheap to reuse, so build it once outside the sort rather than calling localeCompare with options inside the comparator, which rebuilds the collator on every comparison.

99 Truncate on a word boundary, ellipsis included in the budget

easy

Shorten a description to a maximum total length, ellipsis included, cutting at a space so no word is left half-written and no comma or full stop dangles in front of the dots. Leave anything already short enough completely alone, including a string exactly at the limit. Then handle the one input that has no space to cut at.

Stuck? Three hints, smallest first
  1. 1. Return early when text.length <= max. No ellipsis, no change at all.
  2. 2. The room for real text is max - ellipsis.length. Slice one character past it to find out whether you landed mid-word.
  3. 3. Trim trailing spaces and punctuation off the cut before adding the dots.

The answer

function truncate(text, max, ellipsis = '\u2026') {
  if (text.length <= max) return text;
  const room = max - ellipsis.length;
  const oneOver = text.slice(0, room + 1);
  const lastSpace = oneOver.lastIndexOf(' ');
  const kept = lastSpace > 0 ? oneOver.slice(0, lastSpace) : oneOver.slice(0, room);
  return kept.replace(/[\s,.;:!?-]+$/, '') + ellipsis;
}

const blurb = 'Fresh gari from Ibadan, delivered across Lagos within 24 hours.';

const shortEnough = truncate('Gari, 5kg', 20);
const exactlyAtTheLimit = truncate('Gari, 5kg', 9);
const cut = truncate(blurb, 30);
const cutShorter = truncate(blurb, 20);
const noDanglingStop = truncate('Delivered in Lagos. Ibadan next.', 22);
const oneLongWord = truncate('Ikejaaaaaaaaaaaaaaaaaaaaaaa', 12);
const everyLengthWithinBudget = [shortEnough, exactlyAtTheLimit, cut, cutShorter, noDanglingStop, oneLongWord].map((s) => s.length);

console.log('short enough :', shortEnough);
console.log('cut to 30    :', cut, '(' + cut.length + ')');
console.log('cut to 20    :', cutShorter);
console.log('no space     :', oneLongWord);

Output


      
7 checks that prove this answer

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

7 checks

test('text shorter than the limit comes back untouched, with no dots added', shortEnough, 'Gari, 5kg');
test('text exactly at the limit is untouched too, so the boundary is inclusive', exactlyAtTheLimit, 'Gari, 5kg');
test('a long string is cut at a space, never in the middle of a word', cut, 'Fresh gari from Ibadan\u2026');
test('and a tighter budget simply drops another word', cutShorter, 'Fresh gari from\u2026');
test('trailing punctuation is trimmed off before the ellipsis', noDanglingStop, 'Delivered in Lagos\u2026');
test('a single word with no space in it has to be cut mid-word', oneLongWord, 'Ikejaaaaaaa\u2026');
test('and nothing ever comes back longer than the budget it was given', everyLengthWithinBudget, [9, 9, 23, 16, 19, 12]);

Counting the ellipsis inside the budget is the part that is usually got wrong. slice(0, max) + '…' returns max + 1 characters, which in a fixed-width table or a meta description is exactly the overflow you were truncating to avoid. Subtract the ellipsis first, and the last assertion holds by construction.

Slicing one character past the room is the trick for finding the boundary. If that extra character is a space, the cut at room was already clean; if it is a letter, lastIndexOf(' ') walks back to the start of the word you would have split. Searching within slice(0, room) instead throws away a word that fitted perfectly.

Two things this does not do. It counts UTF-16 code units, so a description containing an emoji can be cut between the halves of a surrogate pair — exercise 100 is that problem in full, and the fix is to work over [...text]. And it says nothing about pixels: the honest way to clamp visible text in a browser is text-overflow: ellipsis in CSS, which knows the font. Truncating in JavaScript is for the places CSS cannot reach, like a <title>, a meta description, an SMS body or a plain-text receipt.

100 Reversing a string without breaking the emoji

fair

Reverse a string. Do it with split('') and again with [...str], and compare the two on a string containing an emoji. Then push the spread version until it breaks too: a flag, and a name written with a combining accent.

Stuck? Three hints, smallest first
  1. 1. split('') splits into UTF-16 code units, and an emoji is two of them.
  2. 2. The iterator behind spread and Array.from walks whole code points, so the pair stays together.
  3. 3. A code point is still not a character: a flag is two code points, and a combining accent is a code point of its own.

The answer

const reverseByUnit = (text) => text.split('').reverse().join('');
const reverseByCodePoint = (text) => [...text].reverse().join('');

const plain = 'Lagos';
const withEmoji = 'Lagos \u{1F389}';        // a party popper, one code point, two code units

const bothAgreeOnAscii = [reverseByUnit(plain), reverseByCodePoint(plain)];
const unitLength = withEmoji.length;
const pointLength = [...withEmoji].length;
const arrayFromIsTheSame = reverseByCodePoint(withEmoji) === Array.from(withEmoji).reverse().join('');

const brokenByUnits = reverseByUnit(withEmoji).includes('\u{1F389}');
const keptByCodePoints = reverseByCodePoint(withEmoji);

// A flag is two regional-indicator code points, so reversing them is legal and wrong.
const nigeria = '\u{1F1F3}\u{1F1EC}';
const flagPoints = [...nigeria].length;
const reversedFlag = reverseByCodePoint(nigeria);
const becameGuinea = reversedFlag === '\u{1F1EC}\u{1F1F3}';

// A combining accent is its own code point, and it attaches to whatever precedes it.
const composed = 'Ad\u00e9';
const decomposed = 'Ade\u0301';
const reversedComposed = reverseByCodePoint(composed);
const reversedDecomposed = reverseByCodePoint(decomposed);
const twiceIsIdentity = reverseByCodePoint(reverseByCodePoint(withEmoji)) === withEmoji;

console.log('by code unit :', reverseByUnit(withEmoji), '<- the emoji is in pieces');
console.log('by code point:', keptByCodePoints);
console.log('the flag     :', nigeria, '->', reversedFlag);
console.log('the accent   :', decomposed, '->', JSON.stringify(reversedDecomposed));

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('on plain ASCII the two versions are indistinguishable', bothAgreeOnAscii, ['sogaL', 'sogaL']);
test('length counts code units while spread counts code points, so they disagree', [unitLength, pointLength], [8, 7]);
test('splitting by code unit tears the surrogate pair apart and loses the emoji', brokenByUnits, false);
test('spreading keeps it whole, and Array.from does exactly the same thing', [keptByCodePoints, arrayFromIsTheSame], ['\u{1F389} sogaL', true]);
test('reversing twice by code point gets the original back', twiceIsIdentity, true);
test('but a flag is two code points, so reversing it names a different country', [flagPoints, becameGuinea], [2, true]);
test('and a precomposed accent survives while a decomposed one lands on nothing', [reversedComposed, reversedDecomposed], ['\u00e9dA', '\u0301edA']);
test('which is why normalising to NFC first is part of the job', reverseByCodePoint(decomposed.normalize('NFC')), '\u00e9dA');

split('') splits into UTF-16 code units, and every emoji above U+FFFF is stored as two of them — a surrogate pair. Reversing the units swaps the pair, and the result is two lone surrogates that no longer name a character at all, which is where the black diamonds in a reversed string come from. Spread and Array.from use the string iterator, which yields whole code points, so the pair travels as one unit. That is the fix worth remembering, and [...str].length is the honest character count in the same way.

Then the harder truth: a code point is still not a character. The sixth assertion reverses the Nigerian flag into Guinea's, because a flag is two regional indicator letters — NG becomes GN — and both are perfectly valid. Skin-tone modifiers, ZWJ sequences like a family emoji, and Devanagari vowel signs all break the same way. The seventh is the same problem in a customer's name: a combining accent applies to the character before it, so reversing puts it on the wrong letter, or on nothing.

The unit that actually matters is the grapheme cluster, and computing it needs the Unicode segmentation rules — Intl.Segmenter with granularity: 'grapheme' in a modern runtime, or a library. Deliberately not used here: the point of the exercise is that [...str] is a real improvement over split('') and still not the last word. Normalising to NFC first, as the last assertion does, covers the accent case cheaply and leaves the flags alone.

101 A natural sort, so item2 comes before item10

fair

Sort a list of names containing numbers the way a person would: item2 before item10. Split each name into runs of digits and runs of everything else, compare run by run, and give the comparator a tie-break so it is total. Then do the same thing in one line with Intl.Collator, and find the number big enough to defeat the hand-written version.

Stuck? Three hints, smallest first
  1. 1. text.match(/\d+|\D+/g) splits into alternating number and text runs.
  2. 2. Compare two number runs numerically and two text runs as text. Fall through when they are equal.
  3. 3. new Intl.Collator('en', { numeric: true }).compare is the built-in version of the whole thing.

The answer

const runs = (text) => text.match(/\d+|\D+/g) ?? [];

function naturalCompare(a, b) {
  const left = runs(a);
  const right = runs(b);
  const shared = Math.min(left.length, right.length);
  for (let i = 0; i < shared; i++) {
    const x = left[i];
    const y = right[i];
    if (/^\d/.test(x) && /^\d/.test(y)) {
      const difference = Number(x) - Number(y);
      if (difference !== 0) return difference;
      continue;                                   // equal numbers: 007 and 7 tie here
    }
    if (x !== y) return x.localeCompare(y, 'en');
  }
  // Same runs so far: the shorter name first, then the raw text so the order is total.
  return left.length - right.length || (a < b ? -1 : a > b ? 1 : 0);
}

const collator = new Intl.Collator('en', { numeric: true });

const files = ['item10', 'item2', 'item1', 'item20', 'item3'];
const plainSort = [...files].sort();
const naturalSort = [...files].sort(naturalCompare);
const collatorSort = [...files].sort(collator.compare);

const invoices = ['Invoice 9.pdf', 'Invoice 10.pdf', 'Invoice 1.pdf', 'Invoice 2.pdf'];
const invoiceOrder = [...invoices].sort(naturalCompare);

const branches = ['ikeja-1', 'apapa-9', 'ikeja-10', 'apapa-10'];
const branchOrder = [...branches].sort(naturalCompare);

const leadingZeros = naturalCompare('item02', 'item2');
const noDigitsAtAll = ['bus stop', 'bus', 'busy'].sort(naturalCompare);

// Past 2^53 the numeric comparison stops being able to tell them apart.
const huge = ['v9007199254740993', 'v9007199254740992'];
const numbersCollapse = Number('9007199254740993') - Number('9007199254740992');
const tieBreakRescuesIt = [...huge].sort(naturalCompare);

console.log('plain sort   :', plainSort);
console.log('natural sort :', naturalSort);
console.log('invoices     :', invoiceOrder);
console.log('past 2^53    :', numbersCollapse, '<- the difference the comparator sees');

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 default sort compares text, so item10 lands before item2', plainSort, ['item1', 'item10', 'item2', 'item20', 'item3']);
test('the natural sort reads the digits as a number', naturalSort, ['item1', 'item2', 'item3', 'item10', 'item20']);
test('and Intl.Collator with numeric: true does the same job in one line', collatorSort, naturalSort);
test('it works with the number in the middle of the name', invoiceOrder, ['Invoice 1.pdf', 'Invoice 2.pdf', 'Invoice 9.pdf', 'Invoice 10.pdf']);
test('text runs still compare as text, so the branch name decides first', branchOrder, ['apapa-9', 'apapa-10', 'ikeja-1', 'ikeja-10']);
test('leading zeros compare equal numerically, so the raw-text tie-break decides', leadingZeros, -1);
test('a list with no digits sorts alphabetically, as it should', noDigitsAtAll, ['bus', 'bus stop', 'busy']);
test('past 2^53 the two numbers subtract to zero, and only the tie-break saves the order', [numbersCollapse, tieBreakRescuesIt], [0, ['v9007199254740992', 'v9007199254740993']]);

Splitting on /\d+|\D+/g is the whole idea: alternating runs, compared pairwise, numbers as numbers and text as text. Everything else is bookkeeping. The tie-break at the end matters more than it looks — a comparator must be consistent, and without it item02 and item2 compare as equal, which makes the result depend on the engine's sort stability rather than on the data.

Then the practical answer: do not write this. Intl.Collator with numeric: true is the same behaviour, implemented against the Unicode collation rules, and the third assertion shows it agreeing. It also handles digits outside ASCII, case and accent sensitivity as options, and locale-specific letter ordering, none of which the hand-rolled version does. Build the collator once and pass collator.compare to sort; constructing it inside the comparator rebuilds it for every comparison.

The last assertion is the honest limit of the manual version. Digits are converted with Number, so two version strings differing past the 53rd bit subtract to exactly 0 and the numeric branch cannot separate them; here the raw-text fallback happens to produce the right answer, which is luck rather than design. For git-style identifiers or long numeric ids, compare the digit runs as strings — by length first, then character by character — or let the collator do it.

102 Pick a projection out of a nested object by path

fair

Given an order and a list of paths like customer.address.city, build a new object containing only those paths, rebuilding the nesting as it goes. A path that does not exist must be left out entirely rather than set to undefined, and a path that runs through null must not throw. Then look at what happens to an array index along the way.

Stuck? Three hints, smallest first
  1. 1. path.split('.').reduce(...) walks down the object; guard each step against null and undefined.
  2. 2. Writing needs the same walk in reverse: create a plain object for each missing level, then assign the last key.
  3. 3. Two paths sharing a prefix must merge into one branch, not overwrite it, which they do if you only create what is missing.

The answer

const getPath = (source, path) =>
  path.split('.').reduce((node, key) => (node === null || node === undefined ? undefined : node[key]), source);

function setPath(target, path, value) {
  const keys = path.split('.');
  let node = target;
  for (let i = 0; i < keys.length - 1; i++) {
    if (node[keys[i]] === undefined) node[keys[i]] = {};    // only create what is missing
    node = node[keys[i]];
  }
  node[keys[keys.length - 1]] = value;
  return target;
}

function pick(source, paths) {
  const out = {};
  for (const path of paths) {
    const value = getPath(source, path);
    if (value !== undefined) setPath(out, path, value);      // a miss leaves no key at all
  }
  return out;
}

const order = {
  id: 'ORD-001',
  status: 'paid',
  totalKobo: 1290000,
  customer: {
    name: 'Adaeze Okonkwo',
    phone: '08031234567',
    address: { street: '14 Balogun Street', city: 'Lagos', lga: 'Ikeja' },
  },
  items: [
    { sku: 'GARI-5', name: 'Gari, 5kg', qty: 2 },
    { sku: 'OIL-1', name: 'Palm oil, 1L', qty: 1 },
  ],
  courier: null,
};

const summary = pick(order, [
  'id',
  'customer.name',
  'customer.address.city',
  'courier.tracking',
  'nowhere.at.all',
  'items.0.name',
]);

const sharedBranch = pick(order, ['customer.address']);
const sameObject = sharedBranch.customer.address === order.customer.address;
const nothingAsked = pick(order, []);
const deepHit = getPath(order, 'customer.address.lga');
const deepMiss = getPath(order, 'customer.address.postcode');
const throughNull = getPath(order, 'courier.tracking.code');

console.log('summary     :', summary);
console.log('shared leaf :', sameObject);
console.log('deep hit    :', deepHit, '| deep miss:', deepMiss);

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 top-level key is copied straight across', summary.id, 'ORD-001');
test('nested paths are rebuilt, and two of them sharing a prefix merge into one branch', summary.customer, { name: 'Adaeze Okonkwo', address: { city: 'Lagos' } });
test('a leaf object is shared by reference, so this is a projection and not a deep copy', sameObject, true);
test('a path running through null gives undefined rather than throwing, however deep', [throughNull, deepMiss], [undefined, undefined]);
test('and a path that missed leaves no key behind at all', [Object.keys(summary), 'courier' in summary, 'nowhere' in summary], [['id', 'customer', 'items'], false, false]);
test('an index in a path reads the array but writes a plain object, so the array shape is lost', summary.items, { 0: { name: 'Gari, 5kg' } });
test('asking for nothing gives an empty object rather than the whole thing', nothingAsked, {});
test('getPath on its own answers a deep hit and a deep miss the same way an optional chain would', [deepHit, order.customer?.address?.postcode], ['Ikeja', undefined]);

Two things make the writer correct. Creating a level only when it is undefined is what lets customer.name and customer.address.city both land in one customer object — assigning {} unconditionally would wipe whichever came first. And skipping the write entirely when the read produced undefined is what keeps a missing path out of the output; setting it explicitly gives you a key whose value is undefined, which serialises to null in JSON and shows up in the UI as an empty field that looks like real data.

The third assertion is the important limitation. This is a projection, not a clone: the leaf values are the same objects, so mutating summary.customer.address.city would change the order. That is usually what you want — it is cheap, and the projection is for reading — but it means a projection is not safe to hand to something that mutates. structuredClone is the answer when you need real isolation.

The sixth assertion is the trap in path-based writing. items.0.name reads perfectly well, because array['0'] is the same as array[0], but the writer has no way to know that 0 meant an array index, so it creates { '0': ... }. The output serialises as an object with a numeric key, and anything expecting to map over it breaks. Real implementations decide by looking at the key — all digits means create an array — which is a choice with its own surprise: a genuinely numeric object key then becomes an array index.

103 A merge that adds to arrays instead of replacing them

fair

Merge an environment override onto a default configuration: nested objects merge key by key, and arrays concatenate rather than being replaced. Show what plain spread does to the same pair, and be precise about three cases the merge has to decide — null, an explicit undefined, and two values whose types disagree.

Stuck? Three hints, smallest first
  1. 1. Spread copies one level. The nested object in the patch replaces the whole nested object in the base.
  2. 2. Recurse only when both sides are plain objects, and concatenate only when both are arrays.
  3. 3. Object.entries visits a key whose value is undefined, so it will overwrite.

The answer

const isPlainObject = (value) =>
  typeof value === 'object' &&
  value !== null &&
  !Array.isArray(value) &&
  Object.getPrototypeOf(value) === Object.prototype;

function merge(base, patch) {
  const out = { ...base };
  for (const [key, value] of Object.entries(patch)) {
    const current = out[key];
    if (Array.isArray(current) && Array.isArray(value)) out[key] = [...current, ...value];
    else if (isPlainObject(current) && isPlainObject(value)) out[key] = merge(current, value);
    else out[key] = value;                          // including null, and including undefined
  }
  return out;
}

const defaults = {
  baseUrl: 'https://api.adauramart.ng',
  timeoutMs: 5000,
  middleware: ['cors', 'logging'],
  retry: { attempts: 3, baseMs: 200 },
  cache: { ttlMs: 60000, keys: ['vendor', 'order'] },
  region: { name: 'Lagos', code: 'LOS' },
};

const production = {
  timeoutMs: 15000,
  middleware: ['auth', 'rate-limit'],
  retry: { attempts: 5 },
  cache: { keys: ['rate'] },
  region: null,
};

const spread = { ...defaults, ...production };
const merged = merge(defaults, production);

const wipedByUndefined = merge(defaults, { timeoutMs: undefined });
const typesDisagree = merge(defaults, { retry: [1, 2] });
const baseUntouched = [defaults.middleware, defaults.retry.attempts];

console.log('spread retry :', spread.retry, '<- baseMs is gone');
console.log('merged retry :', merged.retry);
console.log('middleware   :', merged.middleware);
console.log('region       :', merged.region, '<- null replaces, it does not merge');

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('spread replaces the nested object wholesale, losing the key the patch did not mention', spread.retry, { attempts: 5 });
test('the deep merge keeps it', merged.retry, { attempts: 5, baseMs: 200 });
test('arrays concatenate rather than replace, base first', merged.middleware, ['cors', 'logging', 'auth', 'rate-limit']);
test('which happens at any depth', merged.cache, { ttlMs: 60000, keys: ['vendor', 'order', 'rate'] });
test('a scalar in the patch simply wins', [merged.timeoutMs, merged.baseUrl], [15000, 'https://api.adauramart.ng']);
test('null replaces the object instead of merging into it, because null is not a plain object', merged.region, null);
test('and an explicit undefined replaces too, so there is no way to say "leave this alone"', [wipedByUndefined.timeoutMs, 'timeoutMs' in wipedByUndefined], [undefined, true]);
test('when the two types disagree the patch wins outright', typesDisagree.retry, [1, 2]);
test('neither input was mutated on the way through', baseUntouched, [['cors', 'logging'], 3]);

The first two assertions are the reason deep merge exists. { ...defaults, ...production } copies exactly one level, so a patch that sets retry.attempts silently deletes retry.baseMs. Configuration files are the place this hurts most, because the missing key usually has a sensible default that now is not there, and the failure surfaces somewhere else entirely.

Concatenating arrays is a policy, not a law, and it needs to be a deliberate choice. It is right for middleware, plugins and allow-lists, where an environment adds to what is already there. It is wrong for a list that names a complete set — a single hosts array, say — where appending gives you the union of two environments and a request going somewhere unexpected. Whichever you pick, write it down, because a reader cannot tell from the call.

The undefined case in the seventh assertion is the sharp edge. Object.entries includes a key whose value is undefined, so { timeoutMs: undefined } overwrites a perfectly good default with nothing, and 'timeoutMs' in result is still true. If a patch may legitimately carry undefined values, skip them explicitly — and then you need a separate sentinel for "delete this key", because you have just spent the only natural way of saying it. The Object.getPrototypeOf(value) === Object.prototype line in the plain-object test is what stops the recursion walking into a Date, a Map, a class instance or a RegExp and reassembling it as a bag of properties. Those get replaced whole, which is almost always what was meant. It also excludes Object.create(null), which is a deliberate trade: it is genuinely a plain bag, and treating it as one would also let a __proto__ key in untrusted JSON reach the prototype chain, which is how prototype-pollution bugs in merge helpers get their CVEs.

104 An object to a query string, and back again

fair

Turn a filter object into a query string with URLSearchParams — dropping the keys that were not set, repeating a key for each value in an array — and then parse it back into an object. Assert what the encoding does to a space and to a comma, and be clear about the two things the round trip cannot preserve.

Stuck? Three hints, smallest first
  1. 1. append adds another value under the same key; set replaces. An array wants append per item.
  2. 2. Reading back, getAll(key) gives every value for that key, so a repeat is how an array survives.
  3. 3. Everything in a query string is text. Nothing else survives the trip.

The answer

function toQuery(params) {
  const search = new URLSearchParams();
  for (const [key, value] of Object.entries(params)) {
    if (value === undefined || value === null) continue;     // absent, not empty
    if (Array.isArray(value)) {
      for (const item of value) search.append(key, String(item));
      continue;
    }
    search.set(key, String(value));
  }
  return search.toString();
}

function fromQuery(query, arrayKeys = []) {
  const search = new URLSearchParams(query);
  const out = {};
  for (const key of new Set(search.keys())) {
    const all = search.getAll(key);
    out[key] = all.length > 1 || arrayKeys.includes(key) ? all : all[0];
  }
  return out;
}

const filters = {
  q: 'gari, 5kg',
  city: 'Lagos',
  page: 2,
  active: true,
  tag: ['fresh', 'bulk'],
  note: '',
  skip: null,
  gone: undefined,
  sign: '\u20a6',
};

const query = toQuery(filters);
const back = fromQuery(query);
const types = [typeof back.page, typeof back.active, back.page, back.active];
const droppedKeys = ['skip' in back, 'gone' in back, 'note' in back];

const oneTag = fromQuery(toQuery({ tag: ['fresh'] }));
const oneTagDeclared = fromQuery(toQuery({ tag: ['fresh'] }), ['tag']);

const spaceEncoding = [query.includes('gari%2C+5kg'), back.q];
const nairaEncoding = [query.includes('sign=%E2%82%A6'), back.sign];

console.log('query :', query);
console.log('back  :', back);
console.log('types :', types);

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 query string repeats the key for an array and skips what was not set', query, 'q=gari%2C+5kg&city=Lagos&page=2&active=true&tag=fresh&tag=bulk&note=&sign=%E2%82%A6');
test('a space becomes a plus and a comma is percent-encoded, both decoding back exactly', spaceEncoding, [true, 'gari, 5kg']);
test('a repeated key comes back as an array', back.tag, ['fresh', 'bulk']);
test('null and undefined are dropped, while an empty string is kept as a real value', [droppedKeys, back.note], [[false, false, true], '']);
test('every value comes back a string: the number and the boolean are gone', types, ['string', 'string', '2', 'true']);
test('a one-element array degrades to a scalar unless you say the key is a list', [oneTag.tag, oneTagDeclared.tag], ['fresh', ['fresh']]);
test('non-ASCII is percent-encoded as UTF-8 and survives the trip', nairaEncoding, [true, '\u20a6']);
test('and the whole object round-trips as long as you expect strings', back, { q: 'gari, 5kg', city: 'Lagos', page: '2', active: 'true', tag: ['fresh', 'bulk'], note: '', sign: '\u20a6' });

URLSearchParams is the right tool and it has one surprise worth knowing: it encodes a space as +, not %20, because it implements the application/x-www-form-urlencoded rules rather than generic percent-encoding. That is correct for a query string and correct on the way back — its parser reads + as a space — but it means hand-rolling one half with encodeURIComponent, which produces %20, and the other half with URLSearchParams works by luck, and breaks on a literal plus sign in a value.

The fifth assertion is the limit of the format. A query string is text, so page: 2 comes back as '2' and active: true as 'true' — and 'false' is a truthy string, which is the bug this produces in practice. Coerce at the edge, per key, with something that knows what each key should be; do not hope that if (params.active) means what it looks like.

The sixth is the ambiguity nothing can fix. One value is indistinguishable from a one-element list once it is on the wire, so the reader has to be told which keys are lists — hence the arrayKeys argument. The alternative conventions each cost something: tag[]=fresh is a PHP-ism many servers do not parse, and tag=fresh,bulk falls over the first time a value contains a comma, which the q field here does. Dropping null and undefined while keeping '' is the other deliberate line. An absent filter and a filter set to the empty string mean different things to a search endpoint, and note= in the URL is how you say the second one.

105 Memoisation, and why the cache key is the hard part

hard

Write a memoiser that takes the key function as an argument, then break it four ways with the key rather than the recursion: a joined key that two different argument lists share, JSON.stringify flattening undefined and null into the same thing, two equal objects whose keys are in a different order, and keying on the object itself. Count hits and misses so every claim is measurable.

Stuck? Three hints, smallest first
  1. 1. The cache is a Map from key to result. The only interesting decision is how the key is made.
  2. 2. args.join(',') cannot tell ['a,b'] from ['a', 'b'].
  3. 3. JSON.stringify writes undefined, null and NaN in an array all as null.

The answer

function memoise(fn, keyOf) {
  const cache = new Map();
  const wrapped = (...args) => {
    const key = keyOf(...args);
    if (cache.has(key)) {
      wrapped.hits += 1;
      return cache.get(key);
    }
    wrapped.misses += 1;
    const value = fn(...args);
    cache.set(key, value);
    return value;
  };
  wrapped.hits = 0;
  wrapped.misses = 0;
  wrapped.keys = () => [...cache.keys()];
  return wrapped;
}

const joinKey = (...args) => args.join(',');
const jsonKey = (...args) => JSON.stringify(args);
const stableKey = (...args) =>
  JSON.stringify(args, (key, value) =>
    value && typeof value === 'object' && !Array.isArray(value)
      ? Object.fromEntries(Object.entries(value).sort((a, b) => (a[0] < b[0] ? -1 : 1)))
      : value);

// 1. A joined key cannot tell one comma-containing argument from two arguments.
let placeRuns = 0;
const place = (...parts) => { placeRuns += 1; return parts.filter(Boolean).join(' / '); };
const byJoin = memoise(place, joinKey);
const joinedCollision = [byJoin('Lagos,Ikeja'), byJoin('Lagos', 'Ikeja'), byJoin.hits, placeRuns];

const byJson = memoise(place, jsonKey);
const jsonKeepsThemApart = [byJson('Lagos,Ikeja'), byJson('Lagos', 'Ikeja'), byJson.misses];

// 2. JSON.stringify writes undefined, null and NaN in an array identically.
let kindRuns = 0;
const kindOf = (value) => { kindRuns += 1; return value === null ? 'null' : typeof value; };
const kind = memoise(kindOf, jsonKey);
const forUndefined = kind(undefined);
const forNull = kind(null);
const forNaN = kind(Number.NaN);
const threeWayCollision = [forUndefined, forNull, forNaN, kind.keys(), kindRuns];

// 3. Key order changes the JSON, so the same box is measured twice.
let areaRuns = 0;
const area = (box) => { areaRuns += 1; return box.w * box.h; };
const byOrder = memoise(area, jsonKey);
const orderMatters = [byOrder({ w: 2, h: 3 }), byOrder({ h: 3, w: 2 }), byOrder.misses];

const bySorted = memoise(area, stableKey);
const sortedFixesIt = [bySorted({ w: 2, h: 3 }), bySorted({ h: 3, w: 2 }), bySorted.misses, bySorted.hits];

// 4. Keying on the object itself is identity, not equality.
const byIdentity = memoise(area, (box) => box);
const shared = { w: 4, h: 5 };
const identity = [byIdentity(shared), byIdentity(shared), byIdentity({ w: 4, h: 5 }), byIdentity.hits, byIdentity.misses];

const totalRuns = areaRuns;
const cacheGrowth = [byOrder.keys().length, bySorted.keys().length, byIdentity.keys().length];

console.log('joined key :', joinedCollision);
console.log('json key   :', jsonKeepsThemApart);
console.log('undefined, null, NaN share the key', kind.keys());
console.log('identity   :', identity);

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 joined key collides, and the second call is served the first answer as a hit', joinedCollision, ['Lagos,Ikeja', 'Lagos,Ikeja', 1, 1]);
test('a JSON key keeps the two argument lists apart, so both answers are right', jsonKeepsThemApart, ['Lagos,Ikeja', 'Lagos / Ikeja', 2]);
test('undefined, null and NaN all stringify to null, so one key serves all three wrongly', threeWayCollision, ['undefined', 'undefined', 'undefined', ['[null]'], 1]);
test('two identical boxes written in a different key order miss each other', orderMatters, [6, 6, 2]);
test('sorting the keys before stringifying fixes it: one miss, one hit', sortedFixesIt, [6, 6, 1, 1]);
test('keying on the object gives identity: the same object hits, an equal one does not', identity, [20, 20, 20, 1, 2]);
test('the underlying function ran exactly once per miss, never once per call', [totalRuns, byOrder.misses + bySorted.misses + byIdentity.misses], [5, 5]);
test('and nothing is ever evicted: every distinct key is still in the map', cacheGrowth, [2, 1, 2]);

Memoisation also appears in the Recursion section, where the subject is the recursion — the same subproblem being solved thousands of times. Here the recursion is beside the point and the key is the whole exercise, because that is where the bugs are. A memoiser with a wrong key does not run slowly, it returns the wrong answer, which is a far worse failure than the one it was added to fix.

The first three assertions are the same mistake at three depths. args.join(',') flattens structure away, so one argument containing a comma and two arguments look identical. JSON.stringify keeps the structure but flattens three distinct values — undefined, null and NaN — into null, so a function that behaves differently for each gets one cached answer for all three. And key order in an object is part of the JSON, so two objects that are equal in every way that matters produce two entries and two runs. Sorting the keys fixes the last one; the other two need the key function to know something about the arguments.

The sixth assertion is a different tool rather than a worse one. Keying on the object itself is identity comparison, which is wrong when callers build a fresh options object per call and exactly right when the argument is a long-lived entity — a DOM node, a request, a record. That is the case a WeakMap is for, and it brings the one property a Map of stringified keys can never have: the entry disappears when the key object is collected. Which brings the last assertion: A Map-based memoiser is an unbounded cache with no invalidation, and the eighth assertion is that stated plainly — the keys are all still there. That is fine for a pure function over a small domain, and a memory leak for anything keyed on user input. Add a maximum size with LRU eviction, or a TTL as in exercise 90, before this goes anywhere near a long-running process. Also note what memoisation assumes: that fn is pure. Memoise something that reads a clock, a database or a random number and you have cached its first answer for ever.

106 An observable store, and the listener that leaves mid-notification

hard

Build a store: it holds state, subscribe(fn) returns an unsubscribe, and setting state notifies every listener with the new and previous state. Then handle the case that breaks naive implementations — a listener that unsubscribes another listener while the notification is still running — and prove the removed one was never called. Also prove that a listener added during a notification does not receive that notification, that setting an unchanged value notifies nobody, and that one listener throwing does not silence the rest.

Stuck? Three hints, smallest first
  1. 1. Copy the listener list before you walk it, or you are iterating a collection somebody is editing.
  2. 2. A copy is only half the fix: a listener removed after the copy was taken is still in your copy, so check it is still registered before calling it.
  3. 3. Give each unsubscribe a private flag so calling it twice is a no-op rather than a second delete that reports success.

The answer

function createStore(initial) {
  let state = initial;
  const listeners = new Set();
  const failures = [];

  return {
    get state() { return state; },
    get listenerCount() { return listeners.size; },
    get failures() { return failures.slice(); },

    subscribe(fn) {
      listeners.add(fn);
      let live = true;
      return function unsubscribe() {
        if (!live) return false;          // idempotent: a second call does nothing
        live = false;
        listeners.delete(fn);
        return true;
      };
    },

    set(patch) {
      const next = { ...state, ...patch };
      const changed = Object.keys(next).some((key) => next[key] !== state[key]);
      if (!changed) return 0;

      const previous = state;
      state = next;

      let delivered = 0;
      for (const fn of [...listeners]) {        // a snapshot, not the live Set
        if (!listeners.has(fn)) continue;       // left after the snapshot was taken
        delivered++;
        try {
          fn(state, previous);
        } catch (err) {
          failures.push(err.message);
        }
      }
      return delivered;
    },
  };
}

const store = createStore({ till: 'Balogun 1', naira: 0 });

const seen = [];
const off = store.subscribe((now, before) => seen.push(before.naira + ' -> ' + now.naira));
const firstDelivery = store.set({ naira: 4500 });
const afterOneEvent = [seen.slice(), store.state.naira];

off();
store.set({ naira: 9000 });
const ignoredAfterUnsubscribe = seen.length;
const secondOffReturns = off();
const stillEmpty = store.listenerCount;

// A warden that removes the victim, subscribed FIRST so it runs first.
const victimCalls = [];
const wardenCalls = [];
const staged = createStore({ open: false });
let removeVictim;
staged.subscribe(() => { wardenCalls.push('warden ran'); removeVictim(); });
removeVictim = staged.subscribe(() => { victimCalls.push('victim ran'); });
const deliveredWhileRemoving = staged.set({ open: true });
const victimNeverRan = [wardenCalls.length, victimCalls.length, staged.listenerCount];

// A listener that subscribes another one while the notification is in flight.
const lateCalls = [];
const growing = createStore({ n: 0 });
let added = false;
growing.subscribe(() => {
  if (added) return;
  added = true;
  growing.subscribe(() => lateCalls.push('late ran'));
});
const deliveredWhileAdding = growing.set({ n: 1 });
const lateMissedThisRound = lateCalls.length;
const lateGetsTheNextOne = [growing.set({ n: 2 }), lateCalls.length];

const noopSet = store.set({ till: 'Balogun 1' });

const order = [];
const guarded = createStore({ n: 0 });
guarded.subscribe(() => { order.push('first'); throw new Error('listener blew up'); });
guarded.subscribe(() => order.push('second'));
const deliveredDespiteThrow = guarded.set({ n: 1 });

// The naive version, for contrast: it walks the live Set.
function naiveStore() {
  const listeners = new Set();
  return {
    subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
    notify() {
      let delivered = 0;
      for (const fn of listeners) { delivered++; fn(); if (delivered > 50) break; }
      return delivered;
    },
  };
}
const naive = naiveStore();
let grewOnce = false;
naive.subscribe(() => {
  if (grewOnce) return;
  grewOnce = true;
  naive.subscribe(() => {});
});
const naiveDelivered = naive.notify();

console.log('after one event  :', afterOneEvent);
console.log('warden/victim    :', victimNeverRan, 'delivered', deliveredWhileRemoving);
console.log('late subscriber  :', lateMissedThisRound, 'then', lateGetsTheNextOne[1]);
console.log('failures kept    :', guarded.failures);

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 subscriber is handed the new state and the previous one', afterOneEvent, [['0 -> 4500'], 4500]);
test('unsubscribing really stops delivery, and a second call is a no-op', [ignoredAfterUnsubscribe, secondOffReturns, stillEmpty], [1, false, 0]);
test('a listener removed mid-notification is never called', victimNeverRan, [1, 0, 1]);
test('so only the listeners still registered when their turn came are counted', deliveredWhileRemoving, 1);
test('a listener added mid-notification misses that round', [deliveredWhileAdding, lateMissedThisRound], [1, 0]);
test('and receives the next one', lateGetsTheNextOne, [2, 1]);
test('setting a value to what it already was notifies nobody', [noopSet, store.state.till], [0, 'Balogun 1']);
test('one listener throwing does not stop the next, and the error is kept', [deliveredDespiteThrow, order, guarded.failures], [2, ['first', 'second'], ['listener blew up']]);
test('walking the live Set instead would deliver to the listener just added', naiveDelivered, 2);

The whole exercise is in one line: for (const fn of [...listeners]). Iterating the live Set means a listener can edit the collection you are walking, and the last assertion shows what that costs — the listener added mid-notification is visited by the same pass, because a Set iterator does see entries appended during iteration. In a real app that is how one subscribe inside a listener turns into an unbounded loop.

The copy alone is not enough, and that is the trap worth remembering. Once you snapshot, your list is stale in the other direction: a listener that unsubscribed a moment ago is still in your copy, so you would call somebody who has already torn down their component and be reading freed state. The listeners.has(fn) re-check inside the loop is what makes the third assertion true. Two lines, opposite failure modes, and both are needed.

The rest is the small print that separates a working store from a demo. unsubscribe is idempotent, so a component unmounting twice does not report a removal it did not perform. An unchanged value notifies nobody, which is what stops a render loop. And listener errors are collected rather than allowed to propagate, because a notification that aborts halfway leaves half your app updated with no way to tell which half.

107 A computed value that recomputes only when it has to

hard

Build the two halves of a reactive system: a signal that holds a value, and a computed that derives one. A computed must record which signals it read while it ran, cache its result, and recompute only after one of those signals actually changes. Count the recomputations and assert the count. Then prove the hard part — that a computed which stopped reading a signal on its last run is no longer disturbed by it.

Stuck? Three hints, smallest first
  1. 1. Keep one module-level variable naming the computation currently running. A signal read while that is set knows who to call back.
  2. 2. Do not recompute on change. Mark the cache stale and let the next read do the work, which is what makes a diamond recompute once instead of twice.
  3. 3. Record the links in both directions. Before a computation re-runs, remove it from every reader list it joined last time; only rebuilding the links from scratch can drop a dependency the new run does not read.

The answer

let running = null;                   // the computation currently evaluating

// Both sides of every link: readers hold nodes, and each node holds the reader
// sets it joined, so it can resign from all of them before it runs again.
function signal(value) {
  const readers = new Set();
  return {
    get() {
      if (running) { readers.add(running); running.joined.add(readers); }
      return value;
    },
    set(next) {
      if (next === value) return 0;     // equal is not a change
      value = next;
      const notified = readers.size;
      for (const node of [...readers]) node.invalidate();
      return notified;
    },
  };
}

function computed(fn) {
  let cached;
  let fresh = false;
  let runs = 0;
  const readers = new Set();

  const node = {
    joined: new Set(),
    invalidate() {
      if (!fresh) return;               // already stale: do not re-notify
      fresh = false;
      for (const reader of [...readers]) reader.invalidate();
    },
  };

  return {
    get runs() { return runs; },
    get() {
      if (running) { readers.add(running); running.joined.add(readers); }
      if (!fresh) {
        for (const set of node.joined) set.delete(node);   // resign from last run
        node.joined.clear();
        const outer = running;
        running = node;
        try { cached = fn(); runs++; fresh = true; } finally { running = outer; }
      }
      return cached;
    },
  };
}

const quantity = signal(3);
const unitKobo = signal(250000);
const chargeVat = signal(true);
const vatRate = signal(0.075);

const subtotal = computed(() => quantity.get() * unitKobo.get());
const vat = computed(() => (chargeVat.get() ? Math.round(subtotal.get() * vatRate.get()) : 0));
const total = computed(() => subtotal.get() + vat.get());

const first = [total.get(), subtotal.runs, vat.runs, total.runs];
const cachedRead = [total.get(), total.get(), subtotal.runs, total.runs];

const notifiedByQuantity = quantity.set(5);
const afterQuantity = [total.get(), subtotal.runs, vat.runs, total.runs];

const settingSameValue = quantity.set(5);
const afterNoChange = [total.get(), subtotal.runs, total.runs];

// Turning VAT off makes vat stop reading vatRate at all.
chargeVat.set(false);
const withoutVat = [total.get(), vat.runs];
const vatRateNotifiedNobody = vatRate.set(0.05);
const afterMovingAnUnreadSignal = [total.get(), vat.runs, total.runs];

// Turning it back on re-registers the dependency it had dropped.
chargeVat.set(true);
const backOn = total.get();
const vatRateMattersAgain = vatRate.set(0.075);

const diamond = signal(2);
const doubled = computed(() => diamond.get() * 2);
const tripled = computed(() => diamond.get() * 3);
const combined = computed(() => doubled.get() + tripled.get());
const diamondFirst = [combined.get(), combined.runs];
diamond.set(4);
const diamondAfter = [combined.get(), combined.runs, doubled.runs, tripled.runs];

console.log('first read       :', first);
console.log('after quantity   :', afterQuantity);
console.log('vat off          :', withoutVat, 'then', afterMovingAnUnreadSignal);
console.log('diamond          :', diamondFirst, '->', diamondAfter);

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 first read computes each node exactly once', first, [806250, 1, 1, 1]);
test('reading again three times more recomputes nothing', cachedRead, [806250, 806250, 1, 1]);
test('changing a signal notifies only its direct readers', notifiedByQuantity, 1);
test('and the next read recomputes every node that depended on it, once', afterQuantity, [1343750, 2, 2, 2]);
test('setting a signal to the value it already holds changes nothing at all', [settingSameValue, afterNoChange], [0, [1343750, 2, 2]]);
test('with VAT off the total drops and vat has run three times', withoutVat, [1250000, 3]);
test('vatRate is no longer read, so moving it notifies nobody and nothing re-runs', [vatRateNotifiedNobody, afterMovingAnUnreadSignal], [0, [1250000, 3, 3]]);
test('switching VAT back on re-registers the dropped dependency', [backOn, vatRateMattersAgain], [1312500, 1]);
test('a diamond recomputes the shared bottom once per change, not twice', [diamondFirst, diamondAfter], [[10, 1], [20, 2, 2, 2]]);

Dependency tracking needs no declaration list because reading is the declaration. One variable — running — names the computation in flight, and every signal read while it is set quietly adds that computation to its readers. Setting running in a try and restoring it in finally is what makes nesting work: total reads vat, which becomes running for the length of its own body and then hands the flag back.

The seventh assertion is the one that forces the design, and getting it wrong is the usual first mistake. Recording only the forward link — signal knows its readers — makes it impossible to drop a dependency, because nothing on the signal side ever learns that a computation stopped reading it. So each node also keeps the reader sets it joined, and resigns from all of them immediately before it re-runs. On its last run vat took the chargeVat false branch and never touched vatRate, so after resigning it never rejoined, vatRate has no readers at all, and moving it notifies nobody. That is the difference between a system that scales with what is currently read and one that scales with what was ever read.

Invalidation marks stale rather than recomputing, and that laziness buys the last assertion for free. When diamond changes it invalidates doubled and tripled, each of which invalidates combined — but the second invalidation returns immediately because fresh is already false, so combined runs once on the next read. An eager system would recompute combined after doubled updated and again after tripled, and the intermediate value would be visibly wrong. That intermediate is called a glitch, and pull-based evaluation cannot have one.

108 A virtual list: which rows to render for a scroll position

fair

Ten thousand rows cannot all be in the DOM. Write windowFor, which takes the row count, a fixed row height, the viewport height, the current scrollTop and an overscan, and returns the inclusive index range to render plus the top and bottom spacer heights. Get the boundaries exactly right: a viewport that is a whole number of rows, scrolled to an exact row boundary, must render that many rows and not one more. Then do it again for rows of differing heights, using a prefix sum and a binary search.

Stuck? Three hints, smallest first
  1. 1. The first visible row is Math.floor(scrollTop / rowHeight). The last is driven by scrollTop + viewportHeight, and the subtlety is whether that lands on a boundary.
  2. 2. Assert the invariant instead of trusting the arithmetic: padTop + rendered * rowHeight + padBottom must equal the full height, for every scroll position.
  3. 3. For variable heights, build the running offsets once, then binary search for the last offset that is <= scrollTop.

The answer

const clamp = (n, low, high) => Math.min(high, Math.max(low, n));

function windowFor({ total, rowHeight, viewportHeight, scrollTop, overscan = 0 }) {
  const totalHeight = total * rowHeight;
  if (total === 0) return { first: 0, last: -1, count: 0, padTop: 0, padBottom: 0, totalHeight: 0 };

  const maxScroll = Math.max(0, totalHeight - viewportHeight);
  const top = clamp(scrollTop, 0, maxScroll);

  const firstVisible = Math.floor(top / rowHeight);
  // Subtract one pixel: a bottom edge landing exactly on a boundary belongs to
  // the row above it, not the row that starts there.
  const lastVisible = Math.floor((top + viewportHeight - 1) / rowHeight);

  const first = clamp(firstVisible - overscan, 0, total - 1);
  const last = clamp(lastVisible + overscan, 0, total - 1);
  const count = last - first + 1;

  return {
    first,
    last,
    count,
    padTop: first * rowHeight,
    padBottom: totalHeight - (last + 1) * rowHeight,
    totalHeight,
  };
}

const project = (w) => [w.first, w.last, w.count, w.padTop, w.padBottom];
const invariant = (w, rowHeight) => w.padTop + w.count * rowHeight + w.padBottom === w.totalHeight;

const atRest = windowFor({ total: 10000, rowHeight: 40, viewportHeight: 400, scrollTop: 0 });
const onABoundary = windowFor({ total: 10000, rowHeight: 40, viewportHeight: 400, scrollTop: 400 });
const onePixelPast = windowFor({ total: 10000, rowHeight: 40, viewportHeight: 400, scrollTop: 401 });
const withOverscan = windowFor({ total: 10000, rowHeight: 40, viewportHeight: 400, scrollTop: 400, overscan: 2 });
const overscanAtTheTop = windowFor({ total: 10000, rowHeight: 40, viewportHeight: 400, scrollTop: 0, overscan: 3 });
const atTheBottom = windowFor({ total: 10000, rowHeight: 40, viewportHeight: 400, scrollTop: 399600 });
const overscrolled = windowFor({ total: 10000, rowHeight: 40, viewportHeight: 400, scrollTop: 999999 });
const shorterThanViewport = windowFor({ total: 3, rowHeight: 40, viewportHeight: 400, scrollTop: 0 });
const empty = windowFor({ total: 0, rowHeight: 40, viewportHeight: 400, scrollTop: 0 });

let invariantHolds = true;
for (let top = 0; top <= 2000; top += 7) {
  const w = windowFor({ total: 10000, rowHeight: 40, viewportHeight: 400, scrollTop: top, overscan: 2 });
  if (!invariant(w, 40)) invariantHolds = false;
}

// Variable heights: a prefix sum plus a binary search.
function variableWindow(heights, viewportHeight, scrollTop) {
  const offsets = [0];
  for (let i = 0; i < heights.length; i++) offsets.push(offsets[i] + heights[i]);
  const totalHeight = offsets[heights.length];
  const top = clamp(scrollTop, 0, Math.max(0, totalHeight - viewportHeight));

  let low = 0;
  let high = heights.length - 1;
  let first = 0;
  while (low <= high) {
    const mid = (low + high) >> 1;
    if (offsets[mid] <= top) { first = mid; low = mid + 1; } else { high = mid - 1; }
  }

  let last = first;
  while (last + 1 < heights.length && offsets[last + 1] < top + viewportHeight) last++;

  return {
    first,
    last,
    count: last - first + 1,
    padTop: offsets[first],
    padBottom: totalHeight - offsets[last + 1],
    totalHeight,
    probes: offsets.length,
  };
}

const heights = [30, 90, 30, 120, 30, 30, 200, 30];
const varTop = variableWindow(heights, 150, 0);
const varMiddle = variableWindow(heights, 150, 150);
const varBottom = variableWindow(heights, 150, 10000);
const varInvariant = [
  varMiddle.padTop + heights.slice(varMiddle.first, varMiddle.last + 1).reduce((a, b) => a + b, 0) + varMiddle.padBottom,
  varMiddle.totalHeight,
];

console.log('at rest        :', project(atRest));
console.log('on a boundary  :', project(onABoundary), 'one px past:', project(onePixelPast));
console.log('at the bottom  :', project(atTheBottom));
console.log('variable middle:', [varMiddle.first, varMiddle.last, varMiddle.padTop, varMiddle.padBottom]);

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('at rest a 400px viewport of 40px rows renders exactly ten rows', project(atRest), [0, 9, 10, 0, 399600]);
test('scrolled to an exact boundary it still renders ten, not eleven', project(onABoundary), [10, 19, 10, 400, 399200]);
test('one pixel past that boundary it renders eleven, straddling two rows', project(onePixelPast), [10, 20, 11, 400, 399160]);
test('overscan widens the window on both sides and clamps at the top', [project(withOverscan), project(overscanAtTheTop)], [[8, 21, 14, 320, 399120], [0, 12, 13, 0, 399480]]);
test('at the bottom there is no bottom spacer, and overscrolling clamps to the same window', [project(atTheBottom), same(project(overscrolled), project(atTheBottom))], [[9990, 9999, 10, 399600, 0], true]);
test('a list shorter than the viewport renders all of it, and an empty one nothing', [project(shorterThanViewport), project(empty)], [[0, 2, 3, 0, 0], [0, -1, 0, 0, 0]]);
test('padTop plus rendered plus padBottom equals the full height at every position tried', invariantHolds, true);
test('with variable heights the binary search finds the row a scrollTop lands in', [[varTop.first, varTop.last], [varMiddle.first, varMiddle.last], [varBottom.first, varBottom.last]], [[0, 2], [3, 4], [6, 7]]);
test('and the spacers still account for exactly the rows not rendered', varInvariant, [560, 560]);

The - 1 in Math.floor((top + viewportHeight - 1) / rowHeight) is the entire exercise. Without it, a 400px viewport of 40px rows scrolled to exactly 400 computes a last index of 20 and renders eleven rows, one of which is a zero-pixel sliver below the fold. It is invisible, it is not a bug anyone reports, and it is a row of real DOM per scroll position for the life of the app.

The eighth assertion is the shape of test worth copying. Rather than enumerating scroll positions and their expected windows, it states the property that must hold for all of them — the spacers plus the rendered rows account for exactly the full scroll height — and checks it across a sweep. If that invariant ever breaks, the scrollbar length changes as you scroll, which is the most disorienting bug a virtual list can have.

The variable-height half is why virtual lists are hard in practice. Fixed heights make the row at a scroll position a division; differing heights make it a search, so you build the running offsets once and binary search them. What the code here does not solve is that real row heights are unknown until measured, so production implementations guess, render, measure, and patch the offsets — which shifts every offset after the corrected row and can move the content under the reader's finger.

109 A priority queue on a binary heap, and the tie it gets wrong

hard

Implement a min-heap as a flat array: push sifts up, pop moves the last element to the root and sifts down. Expose peek and size. Prove the backing array is not in sorted order even though every pop comes out in order. Then find the flaw: two items of equal priority do not come out in the order they went in. Fix it with a sequence number in the comparator and prove the fix. Finish by using a bounded heap to find the three largest of a list without sorting it.

Stuck? Three hints, smallest first
  1. 1. For the node at i, the parent is (i - 1) >> 1 and the children are 2i + 1 and 2i + 2.
  2. 2. Sifting down means swapping with the smaller of the two children, and stopping when neither is smaller than you.
  3. 3. A heap cannot be stable, because sifting moves elements past each other. Break ties on an ever-increasing counter and stability is restored by the comparator, not by the heap.

The answer

class Heap {
  constructor(compare) {
    this.compare = compare;
    this.items = [];
  }
  get size() { return this.items.length; }
  peek() { return this.items.length ? this.items[0] : undefined; }

  push(item) {
    const a = this.items;
    a.push(item);
    let i = a.length - 1;
    while (i > 0) {
      const parent = (i - 1) >> 1;
      if (this.compare(a[i], a[parent]) >= 0) break;
      [a[i], a[parent]] = [a[parent], a[i]];
      i = parent;
    }
    return this.size;
  }

  pop() {
    const a = this.items;
    if (a.length === 0) return undefined;
    const top = a[0];
    const last = a.pop();
    if (a.length) {
      a[0] = last;
      let i = 0;
      for (;;) {
        const left = 2 * i + 1;
        const right = left + 1;
        let small = i;
        if (left < a.length && this.compare(a[left], a[small]) < 0) small = left;
        if (right < a.length && this.compare(a[right], a[small]) < 0) small = right;
        if (small === i) break;
        [a[i], a[small]] = [a[small], a[i]];
        i = small;
      }
    }
    return top;
  }
}

const byNumber = (a, b) => a - b;
const numbers = new Heap(byNumber);
for (const n of [5, 3, 8, 1, 9, 2, 7]) numbers.push(n);
const rawArray = numbers.items.slice();
const rootIsSmallest = [numbers.peek(), numbers.size];

const drained = [];
while (numbers.size) drained.push(numbers.pop());
const emptyBehaviour = [numbers.pop(), numbers.peek(), numbers.size];

// Every parent no greater than its children, checked directly.
const shaped = new Heap(byNumber);
for (const n of [42, 17, 93, 4, 61, 8, 25, 3, 70, 51]) shaped.push(n);
let heapProperty = true;
for (let i = 1; i < shaped.items.length; i++) {
  if (shaped.items[(i - 1) >> 1] > shaped.items[i]) heapProperty = false;
}

// Equal priorities, unstable.
const tickets = [
  { name: 'Adaeze', priority: 2 },
  { name: 'Bode', priority: 1 },
  { name: 'Chidi', priority: 2 },
  { name: 'Dayo', priority: 2 },
  { name: 'Emeka', priority: 1 },
  { name: 'Funke', priority: 2 },
];
const unstable = new Heap((a, b) => a.priority - b.priority);
for (const t of tickets) unstable.push(t);
const unstableOrder = [];
while (unstable.size) unstableOrder.push(unstable.pop().name);
const insertionOrderOfTwos = tickets.filter((t) => t.priority === 2).map((t) => t.name);
const poppedTwos = unstableOrder.filter((n) => insertionOrderOfTwos.includes(n));

let seq = 0;
const stable = new Heap((a, b) => a.priority - b.priority || a.seq - b.seq);
for (const t of tickets) stable.push({ ...t, seq: seq++ });
const stableOrder = [];
while (stable.size) stableOrder.push(stable.pop().name);

// Three largest, using a min-heap of at most three.
const readings = [12, 45, 3, 78, 23, 91, 6, 67, 34, 88, 19];
const topThree = new Heap(byNumber);
let heldAtRest = 0;
for (const r of readings) {
  topThree.push(r);
  if (topThree.size > 3) topThree.pop();
  heldAtRest = Math.max(heldAtRest, topThree.size);
}
const largestThree = [];
while (topThree.size) largestThree.push(topThree.pop());

console.log('backing array :', rawArray);
console.log('popped        :', drained);
console.log('unstable twos :', poppedTwos, 'vs insertion', insertionOrderOfTwos);
console.log('largest three :', largestThree);

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 backing array is heap-ordered, and that is not the same as sorted', [rawArray, same(rawArray, [1, 2, 3, 5, 7, 8, 9])], [[1, 3, 2, 5, 9, 8, 7], false]);
test('yet the root is the smallest, and peek does not remove it', rootIsSmallest, [1, 7]);
test('popping repeatedly yields every item in order', drained, [1, 2, 3, 5, 7, 8, 9]);
test('an empty heap pops undefined instead of throwing', emptyBehaviour, [undefined, undefined, 0]);
test('every parent is no greater than its children after ten pushes', [heapProperty, shaped.items.length], [true, 10]);
test('equal priorities do NOT come out in the order they went in', [poppedTwos, same(poppedTwos, insertionOrderOfTwos)], [['Adaeze', 'Dayo', 'Chidi', 'Funke'], false]);
test('adding a sequence number to the comparator makes ties insertion-ordered', stableOrder, ['Bode', 'Emeka', 'Adaeze', 'Chidi', 'Dayo', 'Funke']);
test('a min-heap capped at three finds the three largest of eleven readings', largestThree, [78, 88, 91]);
test('having never held more than three of them at once', heldAtRest, 3);

A heap is the answer to a question sorting answers too well. Sorting gives total order for n log n; a heap gives you the minimum for log n per operation and never promises anything about the rest. The first assertion is that trade made visible — [1, 3, 2, 5, 9, 8, 7] is not sorted and never will be, it only guarantees each parent is no greater than its children, which is exactly enough for the root to be the minimum.

The instability in the sixth assertion is not a bug in this implementation, it is a property of the structure. Sifting swaps distant elements past one another, so two items that compare equal have no defined order at all — here Dayo, pushed fourth, comes out ahead of Chidi, pushed third, for no reason a caller could predict. Anyone who has watched a job queue run tasks of the same priority out of order has met this. The fix belongs in the comparator, not the heap: a monotonically increasing sequence number turns every tie into a strict comparison, and stability follows.

The last two assertions are the pattern worth stealing. To find the k largest of a stream, keep a min-heap of size k and evict the root whenever it overflows; the smallest of the survivors is always the one at hand to discard. It is O(n log k) with O(k) memory, so it works on a stream you cannot hold, where sort().slice(-3) needs all of it in memory first.

110 Fewest roads between two cities, and what BFS will not tell you

fair

Given a list of roads between Nigerian cities, build an undirected adjacency map and find the route with the fewest hops between two cities using breadth-first search. Return the whole path, not just its length, by keeping a map of how each city was first reached. Handle a city that cannot be reached at all, and a start that is also the goal. Then prove two things: that marking a city seen when you queue it rather than when you visit it saves real work, and that fewest hops is not the same as shortest distance.

Stuck? Three hints, smallest first
  1. 1. The queue holds cities to visit. Alongside it keep a Map from each city to the city you reached it from, and the path falls out by walking that map backwards from the goal.
  2. 2. Mark a city as seen at the moment you push it. Wait until you pop it and the same city gets pushed once per edge that points at it.
  3. 3. Breadth-first search counts edges, so it is only a shortest path when every edge costs the same. Give one road a length and the answer changes.

The answer

const roads = [
  ['Lagos', 'Ibadan', 130],
  ['Ibadan', 'Ilorin', 160],
  ['Ibadan', 'Benin', 300],
  ['Lagos', 'Benin', 320],
  ['Ilorin', 'Abuja', 350],
  ['Ilorin', 'Lokoja', 250],
  ['Benin', 'Onitsha', 130],
  ['Benin', 'Lokoja', 290],
  ['Onitsha', 'Enugu', 110],
  ['Enugu', 'Makurdi', 240],
  ['Lokoja', 'Abuja', 200],
  ['Lokoja', 'Makurdi', 260],
  ['Abuja', 'Kaduna', 190],
  ['Kaduna', 'Kano', 230],
  ['PortHarcourt', 'Yenagoa', 90],
];

function buildGraph(list) {
  const adjacent = new Map();
  const lengths = new Map();
  const link = (from, to) => {
    if (!adjacent.has(from)) adjacent.set(from, []);
    adjacent.get(from).push(to);
  };
  for (const [a, b, km] of list) {
    link(a, b);
    link(b, a);
    lengths.set(a + '|' + b, km);
    lengths.set(b + '|' + a, km);
  }
  return { adjacent, lengths };
}

const { adjacent, lengths } = buildGraph(roads);
const kmOf = (path, table) => {
  let total = 0;
  for (let i = 1; i < path.length; i++) total += table.get(path[i - 1] + '|' + path[i]);
  return total;
};

// Seen is set at push time. The counter is how many pushes that costs.
function shortestHops(adjacent, start, goal) {
  if (!adjacent.has(start) || !adjacent.has(goal)) return { path: null, hops: -1, pushes: 0 };
  const cameFrom = new Map([[start, null]]);
  const levels = new Map([[start, 0]]);
  let queue = [start];
  let pushes = 1;

  while (queue.length) {
    const next = [];
    for (const city of queue) {
      if (city === goal) {
        const path = [];
        for (let at = goal; at !== null; at = cameFrom.get(at)) path.unshift(at);
        return { path, hops: path.length - 1, pushes, levels };
      }
      for (const neighbour of adjacent.get(city)) {
        if (cameFrom.has(neighbour)) continue;      // already queued by somebody
        cameFrom.set(neighbour, city);
        levels.set(neighbour, levels.get(city) + 1);
        next.push(neighbour);
        pushes++;
      }
    }
    queue = next;
  }
  return { path: null, hops: -1, pushes, levels };
}

// The same walk, but a city is only marked seen once it is popped.
function lateMarking(adjacent, start, goal) {
  const seen = new Set();
  const queue = [start];
  let pushes = 1;
  let popped = 0;
  while (queue.length) {
    const city = queue.shift();
    popped++;
    if (seen.has(city)) continue;
    seen.add(city);
    if (city === goal) return { pushes, popped, reached: true };
    for (const neighbour of adjacent.get(city)) {
      if (seen.has(neighbour)) continue;
      queue.push(neighbour);
      pushes++;
    }
  }
  return { pushes, popped, reached: false };
}

const toKano = shortestHops(adjacent, 'Lagos', 'Kano');
const levelSpread = [toKano.levels.size, toKano.levels.get('Abuja'), toKano.levels.get('Kano'), toKano.levels.has('Yenagoa')];
const sameCity = shortestHops(adjacent, 'Lagos', 'Lagos');
const unreachable = shortestHops(adjacent, 'Lagos', 'Yenagoa');
const notOnTheMap = shortestHops(adjacent, 'Lagos', 'Sokoto');

const late = lateMarking(adjacent, 'Lagos', 'Kano');
const pushComparison = [toKano.pushes, late.pushes, late.pushes > toKano.pushes];

let symmetric = true;
let directedEntries = 0;
for (const [city, neighbours] of adjacent) {
  directedEntries += neighbours.length;
  for (const n of neighbours) if (!adjacent.get(n).includes(city)) symmetric = false;
}

const component = (start) => {
  const seen = new Set([start]);
  const queue = [start];
  while (queue.length) {
    for (const n of adjacent.get(queue.shift())) if (!seen.has(n)) { seen.add(n); queue.push(n); }
  }
  return seen.size;
};
const twoComponents = [component('Lagos'), component('Yenagoa')];

// One long straight road, and fewest hops stops being cheapest.
const expressway = buildGraph([...roads, ['Lagos', 'Abuja', 750]]);
const byHops = shortestHops(expressway.adjacent, 'Lagos', 'Abuja');
const viaIbadan = ['Lagos', 'Ibadan', 'Ilorin', 'Abuja'];
const hopsVsKm = [byHops.hops, kmOf(byHops.path, expressway.lengths), viaIbadan.length - 1, kmOf(viaIbadan, expressway.lengths)];

console.log('Lagos to Kano :', toKano.path.join(' -> '), '=', toKano.hops, 'hops');
console.log('levels        :', levelSpread);
console.log('pushes        :', pushComparison);
console.log('hops vs km    :', hopsVsKm);

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 fewest-hops route to Kano is found whole, not just measured', [toKano.path, toKano.hops], [['Lagos', 'Ibadan', 'Ilorin', 'Abuja', 'Kaduna', 'Kano'], 5]);
test('the level map records how far every reachable city is, and omits the rest', levelSpread, [11, 3, 5, false]);
test('a start that is also the goal is a path of one city and no hops', [sameCity.path, sameCity.hops], [['Lagos'], 0]);
test('a city in another component, and a city not on the map at all, both give null', [unreachable.path, notOnTheMap.path, notOnTheMap.pushes], [null, null, 0]);
test('the graph really is undirected: every road appears in both lists', [symmetric, directedEntries], [true, 30]);
test('marking at push time queues each city once; marking at pop time queues more', pushComparison, [11, 15, true]);
test('and the late version pops cities it has already dealt with', late.popped > 11, true);
test('the two components are 11 cities and 2, which is why Yenagoa is unreachable', twoComponents, [11, 2]);
test('fewest hops is not shortest distance: one hop of 750km beats three totalling 640km on hops alone', hopsVsKm, [1, 750, 3, 640]);

Breadth-first search answers "fewest edges" and nothing else. The last assertion is that limitation stated as plainly as it can be: with an expressway in the graph, BFS reports a one-hop route of 750km and is entirely correct, while a driver would take the three-hop route of 640km. The moment edges have different costs you need Dijkstra, which is the same walk with a priority queue instead of a plain one — exercise 109 built the queue it needs.

The cameFrom map is what turns a search into an answer. Recording the city you arrived from, at the moment you first queue a city, gives a tree of first-arrivals; walking it backwards from the goal reconstructs the route. Because BFS reaches every city by the shortest hop count, the first arrival is the one worth keeping, which is why the map is written once and never updated.

The push comparison is the mistake almost everybody makes once. Marking a city seen when you pop it looks equivalent and is not: between being queued and being popped, every other edge pointing at that city queues it again, so the queue holds duplicates and grows with the edge count rather than the city count. Here it is 15 pushes instead of 11 on a graph of 15 roads, and cities popped a second time only to be skipped; on a dense graph the queue grows with the edge count rather than the city count, which is the difference between linear and quadratic. Mark at push time, always.

111 Topological sort, and naming the cycle when there is one

hard

Order a set of tasks so that every task comes after everything it depends on. Use Kahn's algorithm: count incoming edges, repeatedly take a task with none left, and decrement its dependents. Break ties deterministically so the result is reproducible, and prove that a different tie-break gives a different order that is just as valid. Then handle the case Kahn only half-answers: when the input contains a cycle it stops early and can tell you how many tasks are stuck but not which ones form the loop. Add a depth-first pass that reports the cycle itself, as a path.

Stuck? Three hints, smallest first
  1. 1. Build a map of dependents and a map of incoming counts. Every task with a count of zero is ready.
  2. 2. If you finish with fewer tasks emitted than you started with, whatever is left is inside or downstream of a cycle. That is the detection; it is not the diagnosis.
  3. 3. For the diagnosis, walk depth-first with three colours: untouched, on the current stack, finished. Meeting a node that is on the current stack means the cycle is the slice of the stack from that node onwards.

The answer

function topoSort(nodes, edges, { tieBreak = (a, b) => (a < b ? -1 : a > b ? 1 : 0) } = {}) {
  const dependents = new Map(nodes.map((n) => [n, []]));
  const incoming = new Map(nodes.map((n) => [n, 0]));
  for (const [before, after] of edges) {
    dependents.get(before).push(after);
    incoming.set(after, incoming.get(after) + 1);
  }

  const ready = nodes.filter((n) => incoming.get(n) === 0).sort(tieBreak);
  const done = [];
  while (ready.length) {
    const task = ready.shift();
    done.push(task);
    for (const next of dependents.get(task)) {
      incoming.set(next, incoming.get(next) - 1);
      if (incoming.get(next) === 0) {
        ready.push(next);
        ready.sort(tieBreak);
      }
    }
  }

  if (done.length === nodes.length) return { order: done, done, stuck: [], cycle: null };
  const stuck = nodes.filter((n) => !done.includes(n));
  return { order: null, done, stuck, cycle: findCycle(nodes, dependents) };
}

function findCycle(nodes, dependents) {
  const UNTOUCHED = 0;
  const ON_STACK = 1;
  const FINISHED = 2;
  const colour = new Map(nodes.map((n) => [n, UNTOUCHED]));
  const stack = [];
  let found = null;

  const visit = (node) => {
    colour.set(node, ON_STACK);
    stack.push(node);
    for (const next of dependents.get(node)) {
      if (found) break;
      if (colour.get(next) === ON_STACK) {
        found = [...stack.slice(stack.indexOf(next)), next];
        break;
      }
      if (colour.get(next) === UNTOUCHED) visit(next);
    }
    stack.pop();
    colour.set(node, FINISHED);
  };

  for (const node of nodes) {
    if (found) break;
    if (colour.get(node) === UNTOUCHED) visit(node);
  }
  return found;
}

const tasks = ['fitOut', 'hire', 'insurance', 'launch', 'lease', 'permit', 'signage', 'stock', 'train'];
const before = [
  ['lease', 'fitOut'],
  ['permit', 'fitOut'],
  ['fitOut', 'stock'],
  ['stock', 'launch'],
  ['hire', 'train'],
  ['train', 'launch'],
  ['permit', 'signage'],
  ['signage', 'launch'],
];

const forwards = topoSort(tasks, before);
const edgesGoForwards = (order, list) =>
  list.every(([a, b]) => order.indexOf(a) < order.indexOf(b));
const forwardsValid = [edgesGoForwards(forwards.order, before), forwards.order.length, forwards.order.includes('insurance')];

const backwards = topoSort(tasks, before, { tieBreak: (a, b) => (a < b ? 1 : a > b ? -1 : 0) });
const bothValid = [edgesGoForwards(backwards.order, before), same(backwards.order, forwards.order)];

const withCycle = topoSort(tasks, [...before, ['launch', 'permit']]);
const kahnGaveUp = [withCycle.order, withCycle.done, withCycle.stuck.length];

const reported = withCycle.cycle;
const cycleIsReal = (() => {
  const pairs = [...before, ['launch', 'permit']].map(([a, b]) => a + '->' + b);
  let everyStepIsAnEdge = true;
  for (let i = 1; i < reported.length; i++) {
    if (!pairs.includes(reported[i - 1] + '->' + reported[i])) everyStepIsAnEdge = false;
  }
  return [everyStepIsAnEdge, reported[0] === reported[reported.length - 1]];
})();

const selfLoop = topoSort(['audit'], [['audit', 'audit']]);

console.log('alphabetical  :', forwards.order.join(' '));
console.log('reverse       :', backwards.order.join(' '));
console.log('with a cycle  :', withCycle.done, 'stuck:', withCycle.stuck);
console.log('the cycle     :', reported.join(' -> '));

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 task lands after everything it depends on, tie-broken alphabetically', forwards.order, ['hire', 'insurance', 'lease', 'permit', 'fitOut', 'signage', 'stock', 'train', 'launch']);
test('checked directly against every edge, and the task with no edges is still in there', forwardsValid, [true, 9, true]);
test('a reverse tie-break gives a different order that is equally valid', bothValid, [true, false]);
test('reversing the tie-break really does change the order', backwards.order, ['permit', 'signage', 'lease', 'insurance', 'hire', 'train', 'fitOut', 'stock', 'launch']);
test('one extra edge makes a cycle, and Kahn returns no order at all', kahnGaveUp, [null, ['hire', 'insurance', 'lease', 'train'], 5]);
test('the depth-first pass names the loop as a path that returns to its start', reported, ['fitOut', 'stock', 'launch', 'permit', 'fitOut']);
test('and every step of that path is a real edge, first node equal to last', cycleIsReal, [true, true]);
test('a task that depends on itself is a cycle of length two, and nothing can run', [selfLoop.cycle, selfLoop.done, selfLoop.order], [['audit', 'audit'], [], null]);

Kahn's algorithm is a counting argument. A task with no unmet dependencies can run now; running it removes one dependency from each of its dependents; anything that drops to zero joins the ready set. When the ready set empties, either everything has run or what remains is holding itself up. The termination condition is the cycle detection, which is why you get it for free.

What you do not get for free is which tasks form the loop, and the fifth assertion shows the gap: five tasks are stuck, but only four of them are in the cycle — signage is merely downstream of it. Telling a user "5 tasks could not be ordered" is unhelpful; telling them fitOut → stock → launch → permit → fitOut is a bug report. That needs the depth-first pass, and the trick is the three colours. A node coloured on stack is an ancestor of where you are now, so an edge to it closes a loop, and the loop is exactly the slice of the stack from that node to the top.

The third and fourth assertions are the honest caveat about topological order: it is not unique. Any order satisfying every edge is correct, and there are usually many. That matters because a build tool whose task order changes between runs is very hard to debug, so real implementations pin the tie-break — alphabetical, declaration order, anything — and the sorted ready list here is that pin. Note also what an unstable ready set would cost you: the same input would produce different output on different engines, since Array.prototype.sort was only required to be stable from ES2019.

112 Deep equality that survives a cycle

hard

Write deepEqual(a, b) properly. Handle primitives with NaN and negative zero, arrays, plain objects with keys in a different order, Date, RegExp, Map and Set, and objects that reference themselves. Show why comparing JSON.stringify output is not a substitute. Keep a record of the pairs you are already in the middle of comparing so a cycle terminates instead of overflowing the stack, and be honest in the note about the one case a straightforward implementation still gets wrong.

Stuck? Three hints, smallest first
  1. 1. Object.is handles the primitive cases you would otherwise special-case, including NaN and -0.
  2. 2. Compare the key sets, not the key lists, and check that b actually owns each key rather than inheriting it — undefined and missing are not the same.
  3. 3. For cycles, keep a Map from each visited a to the b it was paired with. Meeting the same pair again means assume equal: anything else and you never return.

The answer

function deepEqual(a, b, seen = new Map()) {
  if (Object.is(a, b)) return true;                     // covers NaN, and -0 vs 0
  if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;

  if (seen.get(a) === b) return true;                   // this pair is already in flight
  seen.set(a, b);

  if (a instanceof Date) return a.getTime() === b.getTime();
  if (a instanceof RegExp) return a.source === b.source && a.flags === b.flags;

  if (a instanceof Map) {
    if (a.size !== b.size) return false;
    for (const [key, value] of a) {
      if (!b.has(key)) return false;                    // keys matched by identity
      if (!deepEqual(value, b.get(key), seen)) return false;
    }
    return true;
  }

  if (a instanceof Set) {
    if (a.size !== b.size) return false;
    for (const value of a) if (!b.has(value)) return false;
    return true;
  }

  if (Array.isArray(a)) {
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i], seen)) return false;
    return true;
  }

  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;
  for (const key of keysA) {
    if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
    if (!deepEqual(a[key], b[key], seen)) return false;
  }
  return true;
}

const attempt = (fn) => { try { return fn(); } catch (err) { return err.name; } };

const order1 = { till: 'Balogun 1', items: [{ sku: 'ADR-1', kobo: 450000 }], staff: { name: 'Adaeze' } };
const order2 = { staff: { name: 'Adaeze' }, items: [{ kobo: 450000, sku: 'ADR-1' }], till: 'Balogun 1' };
const reordered = [deepEqual(order1, order2), JSON.stringify(order1) === JSON.stringify(order2)];

const nudged = { ...order2, items: [{ sku: 'ADR-1', kobo: 450001 }] };
const oneKoboOut = deepEqual(order1, nudged);

const nanCase = [deepEqual({ reading: NaN }, { reading: NaN }), JSON.stringify({ reading: NaN })];
const zeroCase = [deepEqual({ drift: 0 }, { drift: -0 }), Object.is(0, -0)];

const missingKey = [deepEqual({ a: 1, b: undefined }, { a: 1 }), JSON.stringify({ a: 1, b: undefined }) === JSON.stringify({ a: 1 })];

const dates = [deepEqual(new Date('2024-03-01T00:00:00Z'), new Date('2024-03-01T00:00:00Z')), deepEqual(new Date('2024-03-01T00:00:00Z'), new Date('2024-03-02T00:00:00Z'))];
const patterns = [deepEqual(/ade/gi, /ade/gi), deepEqual(/ade/g, /ade/gi)];

const arrayVsObject = deepEqual([1, 2], { 0: 1, 1: 2, length: 2 });

const maps = [
  deepEqual(new Map([['a', 1], ['b', { n: 2 }]]), new Map([['b', { n: 2 }], ['a', 1]])),
  deepEqual(new Map([['a', 1]]), new Map([['a', 2]])),
];
const sets = [deepEqual(new Set([1, 2, 3]), new Set([3, 2, 1])), deepEqual(new Set([1, 2]), new Set([1, 2, 3]))];

// Two cycles built independently, and one built differently.
const left = { name: 'Lagos' };
left.self = left;
left.pair = { name: 'Abuja', back: left };
const right = { name: 'Lagos' };
right.self = right;
right.pair = { name: 'Abuja', back: right };
const cyclesMatch = deepEqual(left, right);

const wrong = { name: 'Lagos' };
wrong.self = wrong;
wrong.pair = { name: 'Kano', back: wrong };
const cyclesDiffer = deepEqual(left, wrong);
const stringifyGivesUp = attempt(() => JSON.stringify(left));

// The limitation: Map keys are matched by identity, not by value.
const keyedByObject = deepEqual(
  new Map([[{ sku: 'ADR-1' }, 3]]),
  new Map([[{ sku: 'ADR-1' }, 3]])
);
const sameKeyObject = (() => {
  const key = { sku: 'ADR-1' };
  return deepEqual(new Map([[key, 3]]), new Map([[key, 3]]));
})();

console.log('reordered keys :', reordered);
console.log('cycles         :', cyclesMatch, cyclesDiffer, '| stringify:', stringifyGivesUp);
console.log('object map key :', keyedByObject, 'vs the same key object:', sameKeyObject);

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('key order is irrelevant to deepEqual, and decisive for JSON.stringify', reordered, [true, false]);
test('one kobo of difference three levels down is still a difference', oneKoboOut, false);
test('NaN equals NaN here though JSON turns it into null, and 0 is not -0', [nanCase, zeroCase], [[true, '{"reading":null}'], [false, false]]);
test('a key present but undefined is not the same as a key absent, which JSON cannot see', missingKey, [false, true]);
test('Date and RegExp are compared by value, not by reference', [dates, patterns], [[true, false], [true, false]]);
test('an array is never equal to an array-like object: the prototypes differ', arrayVsObject, false);
test('Map and Set are compared by content, in any order, and by size', [maps, sets], [[true, false], [true, false]]);
test('two independently built cyclic objects compare equal, where stringify throws', [cyclesMatch, cyclesDiffer, stringifyGivesUp], [true, false, 'TypeError']);
test('but a Map keyed by an equivalent object is reported unequal, unless the key is the same object', [keyedByObject, sameKeyObject], [false, true]);

JSON.stringify(a) === JSON.stringify(b) is the comparison everybody reaches for and it is wrong in five different ways, four of them asserted here: it depends on key insertion order, it turns NaN into null, it drops keys whose value is undefined so {a:1,b:undefined} and {a:1} serialise identically, and it throws outright on a cycle. It also silently discards functions and symbol keys. It is fine for a cache key over data you control and unsafe as an equality test.

The cycle handling is three lines and worth understanding rather than memorising. seen maps each a already being compared to the b it was paired with; meeting that same pair again means you are back where you started, so you return true and let the rest of the comparison decide. That is not a guess — if some other part of the structure differs, that comparison fails and the whole thing is false, which the cyclesDiffer assertion confirms with a cycle whose partner is named Kano instead of Abuja.

The last assertion is the limitation to be honest about. Map.prototype.has and Set.prototype.has use SameValueZero, so a key or member that is an object matches only itself. Two maps with equivalent but distinct object keys are reported unequal, and fixing it properly means finding a perfect matching between the two key sets — for sets of objects that is bipartite matching, which is why most libraries either do it in O(n²) or document the same restriction. The prototype check has a similar sharp edge in its favour: it is what makes an array unequal to an array-like, and it also means an object made with Object.create(null) never equals a plain {}.

113 A persistent map that shares almost all of itself

hard

Immutable does not have to mean copied. Build a persistent sorted map as a binary search tree where insert returns a new root but rebuilds only the nodes along the path it changed, leaving every other subtree untouched and shared. Prove the sharing with === rather than by claim, count the nodes allocated per insert, and show the older version still reads exactly as it did. Add the identity short-circuit — inserting a value that is already there returns the very same tree — and do the same trick for a linked list whose tail is shared.

Stuck? Three hints, smallest first
  1. 1. A node is { key, value, left, right } and is never mutated. Insert returns a new node whose other child is the old child, passed straight through.
  2. 2. Count allocations in the node factory. If the number is the depth of the path you walked rather than the size of the tree, you have structural sharing.
  3. 3. If the key is present and the value is identical, return the node you were given. That one line makes a no-op insert free, and it propagates all the way up.

The answer

let allocated = 0;
const node = (key, value, left, right) => {
  allocated++;
  return Object.freeze({ key, value, left, right });
};

const insert = (tree, key, value) => {
  if (tree === null) return node(key, value, null, null);
  if (key === tree.key) {
    if (tree.value === value) return tree;                    // nothing to change
    return node(key, value, tree.left, tree.right);
  }
  if (key < tree.key) {
    const left = insert(tree.left, key, value);
    return left === tree.left ? tree : node(tree.key, tree.value, left, tree.right);
  }
  const right = insert(tree.right, key, value);
  return right === tree.right ? tree : node(tree.key, tree.value, tree.left, right);
};

const get = (tree, key) => {
  let at = tree;
  while (at !== null) {
    if (key === at.key) return at.value;
    at = key < at.key ? at.left : at.right;
  }
  return undefined;
};

const entries = (tree, out = []) => {
  if (tree === null) return out;
  entries(tree.left, out);
  out.push([tree.key, tree.value]);
  entries(tree.right, out);
  return out;
};

const size = (tree) => (tree === null ? 0 : 1 + size(tree.left) + size(tree.right));

// Keys chosen so the tree is balanced: 'm' at the root, 'e' and 't' below it.
let version1 = null;
for (const [key, value] of [['m', 1], ['e', 2], ['t', 3], ['c', 4], ['g', 5], ['p', 6], ['w', 7]]) {
  version1 = insert(version1, key, value);
}
const buildCost = allocated;

allocated = 0;
const version2 = insert(version1, 'f', 8);
const insertCost = allocated;
const bothReadable = [entries(version1).length, entries(version2).length, get(version1, 'f'), get(version2, 'f')];
const oldOneUnchanged = entries(version1).map(([k]) => k).join('');

// The right-hand subtree was not on the path, so it is the very same object.
const sharing = [
  version2.right === version1.right,
  version2.left === version1.left,
  version2.left.left === version1.left.left,
  version2 === version1,
];
const totalNodes = [size(version1), size(version2), insertCost];

allocated = 0;
const version3 = insert(version2, 'f', 8);
const noopInsert = [version3 === version2, allocated];

allocated = 0;
const version4 = insert(version2, 'f', 99);
const changedValue = [version4 === version2, allocated, get(version4, 'f'), get(version2, 'f')];

const frozenWrite = (() => {
  try { version1.value = 'tampered'; return 'no error'; } catch (err) { return err.name; }
})();

// The same idea, one dimension down: a cons list sharing its tail.
let listAllocated = 0;
const cons = (head, tail) => { listAllocated++; return Object.freeze({ head, tail }); };
const toArray = (list) => { const out = []; for (let at = list; at !== null; at = at.tail) out.push(at.head); return out; };

listAllocated = 0;
let queue = null;
for (const till of ['Balogun 1', 'Balogun 2', 'Ikeja 1']) queue = cons(till, queue);
const threeCost = listAllocated;
const branchA = cons('Yaba', queue);
const branchB = cons('Ojota', queue);
const listSharing = [branchA.tail === queue, branchB.tail === queue, branchA.tail === branchB.tail];
const listCost = [listAllocated, toArray(branchA), toArray(branchB).length];

console.log('build cost    :', buildCost, 'nodes; one insert cost', insertCost);
console.log('sharing       :', sharing);
console.log('old version   :', oldOneUnchanged);
console.log('list sharing  :', listSharing, 'for', listCost[0], 'nodes total');

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('building seven keys costs 17 nodes, because each insert copies its own path', [buildCost, bothReadable], [17, [7, 8, undefined, 8]]);
test('the older version is untouched: same keys, in order, without the new one', oldOneUnchanged, 'cegmptw');
test('yet one more insert rebuilt only that path: four nodes, for a tree of eight', totalNodes, [7, 8, 4]);
test('so the subtree off the path is literally the same object', sharing, [true, false, true, false]);
test('inserting a value that is already there returns the identical tree, free', noopInsert, [true, 0]);
test('changing that value does allocate, and each version keeps its own', changedValue, [false, 4, 99, 8]);
test('and the nodes are frozen, so a stray write throws under strict mode', frozenWrite, 'TypeError');
test('a cons list shares its whole tail with both branches built from it', listSharing, [true, true, true]);
test('so two five-element lists cost five nodes between them, not ten', listCost, [5, ['Yaba', 'Ikeja 1', 'Balogun 2', 'Balogun 1'], 4]);

Structural sharing is the idea that makes immutable data affordable, and the fourth assertion is the whole of it: version2.right === version1.right. Inserting 'f' allocated four nodes — the new leaf plus the three ancestors on the path to it — and handed every other subtree straight through by reference, so a tree of eight entries exists alongside the tree of seven for the price of four objects. Cost is the depth, O(log n), not the size. Copying the whole map on every write is what people assume immutability means, and it is why they conclude it is too slow.

The first assertion is the price you do pay, stated honestly: building those seven keys one insert at a time allocated 17 nodes, not 7, because every insert copies its own path and most of those copies are immediately superseded. Persistent structures are cheap to update and not free to build, which is why libraries offer a transient or mutable-batch mode for bulk loading and then seal it once.

The identity short-circuit is small and does a lot. insert returns the node it was given when nothing changed, and because each level checks whether its child came back unchanged, that answer propagates all the way to the root and the caller gets back the identical tree. Downstream, a prevProps === nextProps check then skips work correctly rather than by luck, which is exactly how a reference-equality render guard is supposed to earn its keep.

Two cautions. This tree has no balancing, so inserting sorted keys degenerates into a linked list and O(log n) becomes O(n) — real persistent maps use a red-black tree or a hash-array-mapped trie for that reason. And Object.freeze here is one level deep, which is enough because no node is ever mutated by this code; it is a guard against a caller, and under the strict mode these panels run in it throws rather than failing silently, which is the behaviour you want.

114 A tokeniser that can say where it went wrong

hard

Write a tokeniser for a small configuration language — names, numbers, quoted strings, { } = , and # comments — where every token records the line and column it started at. Then use those positions for errors: an unexpected character must report its own line and column and print a caret under it, and an unterminated string must report where the string opened rather than where the input ran out. Finish with an invariant: each token's recorded position must be enough to cut its exact text back out of the source.

Stuck? Three hints, smallest first
  1. 1. One advance() that moves the index is the only place allowed to touch line and column. A newline sets the column back to 1 and adds a line; everything else adds a column.
  2. 2. Capture the line and column into locals before you consume a token, because by the time you know what it was you are already past its start.
  3. 3. For an unterminated string the useful position is the opening quote, which you still have in those locals. Reporting the end of the file tells the reader nothing.

The answer

const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
const isDigit = (c) => c >= '0' && c <= '9';
const startsName = (c) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
const inName = (c) => startsName(c) || isDigit(c);
const sourceLines = (source) => source.split(/\r\n|\r|\n/);

function tokenise(source) {
  const tokens = [];
  let i = 0;
  let line = 1;
  let column = 1;

  // The only place line and column are allowed to change.
  const advance = () => {
    if (source[i] === '\r' && source[i + 1] === '\n') { i += 2; line++; column = 1; return; }
    if (source[i] === '\n' || source[i] === '\r') { i += 1; line++; column = 1; return; }
    i += 1;
    column += 1;
  };

  const fail = (message, atLine, atColumn) => {
    const err = new Error(message + ' at line ' + atLine + ', column ' + atColumn);
    err.name = 'SyntaxError';
    err.line = atLine;
    err.column = atColumn;
    const text = sourceLines(source)[atLine - 1] || '';
    err.caret = text + '\n' + ' '.repeat(atColumn - 1) + '^';
    throw err;
  };

  while (i < source.length) {
    if (isSpace(source[i])) { advance(); continue; }
    if (source[i] === '#') {
      while (i < source.length && source[i] !== '\n' && source[i] !== '\r') advance();
      continue;
    }

    const from = i;
    const atLine = line;
    const atColumn = column;

    if (source[i] === '"') {
      advance();
      while (i < source.length && source[i] !== '"') {
        if (source[i] === '\n' || source[i] === '\r') fail('unterminated string', atLine, atColumn);
        advance();
      }
      if (i >= source.length) fail('unterminated string', atLine, atColumn);
      advance();
      const text = source.slice(from, i);
      tokens.push({ type: 'string', value: text.slice(1, -1), text, line: atLine, column: atColumn });
      continue;
    }

    if (isDigit(source[i])) {
      while (i < source.length && (isDigit(source[i]) || source[i] === '.')) advance();
      const text = source.slice(from, i);
      tokens.push({ type: 'number', value: Number(text), text, line: atLine, column: atColumn });
      continue;
    }

    if (startsName(source[i])) {
      while (i < source.length && inName(source[i])) advance();
      const text = source.slice(from, i);
      tokens.push({ type: 'name', value: text, text, line: atLine, column: atColumn });
      continue;
    }

    if ('{}=,'.indexOf(source[i]) !== -1) {
      const text = source[i];
      advance();
      tokens.push({ type: 'punct', value: text, text, line: atLine, column: atColumn });
      continue;
    }

    fail('unexpected character ' + JSON.stringify(source[i]), atLine, atColumn);
  }

  tokens.push({ type: 'end', value: '', text: '', line, column });
  return tokens;
}

const attempt = (fn) => { try { fn(); return null; } catch (err) { return err; } };

const config = [
  'till "Balogun 1" {',
  '  rate = 0.075   # VAT, as a fraction',
  '  items = 42',
  '}',
].join('\n');

const tokens = tokenise(config);
const placed = tokens.map((t) => t.value + '@' + t.line + ':' + t.column);
const kinds = [tokens.map((t) => t.type).join(' '), typeof tokens[5].value, tokens[5].value];
const commentGone = [tokens.some((t) => String(t.value).indexOf('#') !== -1), tokens[6].value, tokens[6].line];

const stray = attempt(() => tokenise('rate = 0.075\nitems = 42 ?\n}'));
const strayReport = [stray.name, stray.line, stray.column, stray.message];
const strayCaret = stray.caret.split('\n');

const open = attempt(() => tokenise('till "Balogun 1 {\n  rate = 1\n}'));
const openReport = [open.line, open.column, open.message];

const crlf = tokenise('a\r\nb').map((t) => [t.value, t.line, t.column]);

const lines = sourceLines(config);
let cutsBackOut = true;
for (const t of tokens) {
  if (!t.text) continue;
  if (lines[t.line - 1].slice(t.column - 1, t.column - 1 + t.text.length) !== t.text) cutsBackOut = false;
}

console.log('placed  :', placed.join(' '));
console.log('stray   :', strayReport);
console.log('caret   :');
console.log(stray.caret);
console.log('opened  :', openReport);

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 token carries the line and column it started at', placed, ['till@1:1', 'Balogun 1@1:6', '{@1:18', 'rate@2:3', '=@2:8', '0.075@2:10', 'items@3:3', '=@3:9', '42@3:11', '}@4:1', '@4:2']);
test('a number token holds a number, not the text it was cut from', kinds, ['name string punct name punct number name punct number punct end', 'number', 0.075]);
test('the comment leaves no token behind, and the next one is on the next line', commentGone, [false, 'items', 3]);
test('an unexpected character reports its own line and column, not an offset', strayReport, ['SyntaxError', 2, 12, 'unexpected character "?" at line 2, column 12']);
test('and the caret sits exactly under it', strayCaret, ['items = 42 ?', '           ^']);
test('an unterminated string reports where it OPENED, not where the input ended', openReport, [1, 6, 'unterminated string at line 1, column 6']);
test('a CRLF pair is one line break, not two', crlf, [['a', 1, 1], ['b', 2, 1], ['', 2, 2]]);
test('and every token can be cut back out of its own line using its own position', cutsBackOut, true);

A byte offset is not an error message. "Unexpected character at position 24" makes a reader count characters; "line 2, column 12" plus a caret makes the mistake obvious without leaving the message. The cost of that is one function: advance is the only code allowed to move i, so line and column cannot drift out of step with the index. Every tokeniser that reports positions badly has more than one place that increments the index.

The two locals captured before each token — atLine and atColumn — are the other half, and they are what makes the unterminated-string message useful. By the time you discover a string never closed you are at the end of the file, possibly hundreds of lines away, and that position is worthless: the reader needs the opening quote. Every good compiler error is a position remembered before the scan went wrong, not the position where it stopped.

The last assertion is a cheap way to keep a tokeniser honest. If a token's line and column can be used to slice its exact text back out of the source, the position bookkeeping is right for every token, not just the ones a test happened to name. It also catches the CRLF trap: treating \r\n as two breaks makes every subsequent line number one too high on a Windows-authored file, and this invariant fails immediately when it does.

115 A recursive-descent parser for arithmetic, precedence and all

hard

Parse and evaluate arithmetic expressions with the precedence a human expects. Write one function per precedence level — sums call products, products call powers, powers call primaries — so that 2 + 3 * 4 is 14 without a precedence table anywhere. Build a tree, then evaluate it against an environment of variables and functions. Make subtraction left-associative and exponentiation right-associative, and assert both from the tree shape rather than only from the answer. Reject a missing bracket, an unknown name, and trailing input, each with a position.

Stuck? Three hints, smallest first
  1. 1. A loop makes an operator left-associative: parse one operand, then while the next token is your operator, fold what you have into the left side of a new node.
  2. 2. Recursion at the same level makes it right-associative: after ^, call the same function again rather than looping.
  3. 3. The parser is not finished when the tree is built. If the next token is not end-of-input, something was left over, and silently ignoring it is how 2 + 3 4 becomes 5.

The answer

function lex(source) {
  const tokens = [];
  let i = 0;
  while (i < source.length) {
    const c = source[i];
    if (c === ' ') { i++; continue; }
    if (c >= '0' && c <= '9') {
      let text = '';
      while (i < source.length && ((source[i] >= '0' && source[i] <= '9') || source[i] === '.')) text += source[i++];
      tokens.push({ type: 'number', value: Number(text), column: i - text.length + 1 });
      continue;
    }
    if (/[a-zA-Z_]/.test(c)) {
      let text = '';
      while (i < source.length && /[a-zA-Z0-9_]/.test(source[i])) text += source[i++];
      tokens.push({ type: 'name', value: text, column: i - text.length + 1 });
      continue;
    }
    if ('+-*/%^(),'.indexOf(c) !== -1) { tokens.push({ type: 'op', value: c, column: i + 1 }); i++; continue; }
    const err = new Error('unexpected character ' + JSON.stringify(c) + ' at column ' + (i + 1));
    err.name = 'SyntaxError';
    throw err;
  }
  tokens.push({ type: 'end', value: '', column: source.length + 1 });
  return tokens;
}

function parse(source) {
  const tokens = lex(source);
  let at = 0;

  const peek = () => tokens[at];
  const fail = (wanted) => {
    const t = tokens[at];
    const found = t.type === 'end' ? 'end of input' : JSON.stringify(String(t.value));
    const err = new Error('expected ' + wanted + ' but found ' + found + ' at column ' + t.column);
    err.name = 'SyntaxError';
    err.column = t.column;
    throw err;
  };
  const eat = (value) => {
    if (tokens[at].type === 'op' && tokens[at].value === value) { at++; return true; }
    return false;
  };

  // sum := product (('+' | '-') product)*        loop, so left-associative
  const sum = () => {
    let left = product();
    for (;;) {
      if (eat('+')) left = { op: '+', left, right: product() };
      else if (eat('-')) left = { op: '-', left, right: product() };
      else return left;
    }
  };

  // product := unary (('*' | '/' | '%') unary)*
  const product = () => {
    let left = unary();
    for (;;) {
      if (eat('*')) left = { op: '*', left, right: unary() };
      else if (eat('/')) left = { op: '/', left, right: unary() };
      else if (eat('%')) left = { op: '%', left, right: unary() };
      else return left;
    }
  };

  // unary := ('-' | '+') unary | power           so -2 ^ 2 is -(2 ^ 2)
  const unary = () => {
    if (eat('-')) return { op: 'neg', operand: unary() };
    if (eat('+')) return unary();
    return power();
  };

  // power := primary ('^' unary)?                recursion, so right-associative
  const power = () => {
    const base = primary();
    if (eat('^')) return { op: '^', left: base, right: unary() };
    return base;
  };

  const primary = () => {
    const t = peek();
    if (t.type === 'number') { at++; return { num: t.value }; }
    if (t.type === 'name') {
      at++;
      if (eat('(')) {
        const args = [];
        if (!eat(')')) {
          for (;;) {
            args.push(sum());
            if (eat(',')) continue;
            if (eat(')')) break;
            fail('"," or ")"');
          }
        }
        return { call: t.value, args };
      }
      return { name: t.value };
    }
    if (eat('(')) {
      const inner = sum();
      if (!eat(')')) fail('")"');
      return inner;
    }
    return fail('a number, a name or "("');
  };

  const tree = sum();
  if (peek().type !== 'end') fail('end of input');
  return tree;
}

const FUNCTIONS = { min: Math.min, max: Math.max, abs: Math.abs };

function evaluate(node, env = {}) {
  if ('num' in node) return node.num;
  if ('name' in node) {
    if (!Object.prototype.hasOwnProperty.call(env, node.name)) {
      const err = new Error('unknown name "' + node.name + '"');
      err.name = 'ReferenceError';
      throw err;
    }
    return env[node.name];
  }
  if ('call' in node) {
    const fn = FUNCTIONS[node.call];
    if (!fn) {
      const err = new Error('unknown function "' + node.call + '"');
      err.name = 'ReferenceError';
      throw err;
    }
    return fn(...node.args.map((a) => evaluate(a, env)));
  }
  if (node.op === 'neg') return -evaluate(node.operand, env);
  const l = evaluate(node.left, env);
  const r = evaluate(node.right, env);
  if (node.op === '+') return l + r;
  if (node.op === '-') return l - r;
  if (node.op === '*') return l * r;
  if (node.op === '/') return l / r;
  if (node.op === '%') return l % r;
  return Math.pow(l, r);
}

const run = (source, env) => evaluate(parse(source), env);
const errorOf = (fn) => { try { fn(); return 'no error'; } catch (err) { return err.name + ': ' + err.message; } };

const precedence = [run('2 + 3 * 4'), run('(2 + 3) * 4'), run('2 * 3 + 4 * 5')];
const leftAssociative = [run('10 - 3 - 2'), parse('10 - 3 - 2')];
const rightAssociative = [run('2 ^ 3 ^ 2'), parse('2 ^ 3 ^ 2')];
const unaryVsPower = [run('-2 ^ 2'), run('(-2) ^ 2'), errorOf(() => new Function('return -2 ** 2'))];

const env = { rate: 0.075, items: 42, floor: 2 };
const withNames = [run('items * rate', env), run('max(items * rate, floor) + 1', env), run('abs(0 - items)', env)];

const unknownName = errorOf(() => run('items * vat', env));
const missingBracket = errorOf(() => run('2 * (3 + 4'));
const leftovers = errorOf(() => run('2 + 3 4'));
const emptyInput = errorOf(() => run(''));
const oddEdges = [run('7 / 0') === Infinity, run('0 - 7 / 0') === -Infinity, run('7 % 3'), run('0 - 7 % 3')];

console.log('precedence      :', precedence);
console.log('10 - 3 - 2 tree :', JSON.stringify(leftAssociative[1]));
console.log('2 ^ 3 ^ 2 tree  :', JSON.stringify(rightAssociative[1]));
console.log('errors          :', [unknownName, missingBracket, leftovers]);

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('one function per precedence level gets multiplication first, with no table', precedence, [14, 20, 26]);
test('subtraction is left-associative, and the tree nests to the left to prove it', leftAssociative, [5, { op: '-', left: { op: '-', left: { num: 10 }, right: { num: 3 } }, right: { num: 2 } }]);
test('exponentiation is right-associative, and nests the other way', rightAssociative, [512, { op: '^', left: { num: 2 }, right: { op: '^', left: { num: 3 }, right: { num: 2 } } }]);
test('unary minus is weaker than ^, which is why JavaScript refuses -2 ** 2 outright', unaryVsPower, [-4, 4, 'SyntaxError: Unary operator used immediately before exponentiation expression. Parenthesis must be used to disambiguate operator precedence']);
test('names and calls resolve against the environment handed to the evaluator', withNames, [3.15, 4.15, 42]);
test('an unknown name is a ReferenceError naming it, not a silent NaN', unknownName, 'ReferenceError: unknown name "vat"');
test('a missing bracket says what it wanted and where it gave up', missingBracket, 'SyntaxError: expected ")" but found end of input at column 11');
test('trailing input is rejected rather than quietly ignored, and empty input too', [leftovers, emptyInput], ['SyntaxError: expected end of input but found "4" at column 7', 'SyntaxError: expected a number, a name or "(" but found end of input at column 1']);
test('division by zero gives a signed Infinity, and % keeps the sign of the left operand', oddEdges, [true, true, 1, -1]);

Precedence needs no table because it is in the shape of the call graph. sum can only build a + node out of whatever product returned, so a product is always finished before a sum can see it, and 2 + 3 * 4 comes out as 2 + (3 * 4) with nothing deciding that explicitly. Adding a level means adding a function between two existing ones — that is the whole appeal of recursive descent, and why hand-written parsers outnumber generated ones in real compilers.

Associativity is the difference between a loop and a recursion, and the second and third assertions show it in the trees rather than in the answers. sum loops and folds each new operand into the left slot, so 10 - 3 - 2 nests leftwards and evaluates to 5; get it wrong and you get 10 - (3 - 2), which is 9, and a plausible-looking 9 is a very hard bug to see. power calls back into unary for its right operand, so 2 ^ 3 ^ 2 nests rightwards and is 512 rather than 64.

The fourth assertion is the trap the language itself refuses to fall into. Putting unary minus below ^ makes -2 ^ 2 equal -(2 ^ 2), which is −4 and matches ordinary mathematical notation — but plenty of languages and spreadsheets choose the other reading and return 4. The disagreement is real enough that JavaScript declines to pick: -2 ** 2 is a syntax error, and the engine tells you to add brackets.

The last piece of the parser is the easiest to leave out. After the tree is built, the next token must be end-of-input; without that check 2 + 3 4 parses as 2 + 3 and the stray 4 disappears. A parser that accepts a prefix of its input and ignores the rest is worse than one that rejects, because the answer looks right.

116 A JSON parser by hand, checked against the real one

hard

Write a JSON parser: objects, arrays, strings with all six short escapes and \uXXXX, numbers, and the three literals. Then hold it to the only standard that matters by comparing every result with JSON.parse on the same text — including the inputs both must reject, such as a trailing comma, a single-quoted string and NaN. Two behaviours are worth discovering rather than assuming: what happens to a duplicate key, and what happens when the key is __proto__.

Stuck? Three hints, smallest first
  1. 1. One index, one skipSpace, and one value() that dispatches on the current character. Every helper leaves the index just past what it consumed.
  2. 2. JSON numbers are narrower than JavaScript numbers: no leading +, no leading zeros, no bare .5, no NaN. Match the grammar with a regex anchored at your position and let Number do the conversion.
  3. 3. Assigning a parsed key with object[key] = value is a mistake for exactly one key. Use Object.defineProperty, and compare what the two versions do with __proto__.

The answer

const BACKSLASH = String.fromCharCode(92);   // written this way to keep the escaping readable
const SHORT = { '"': '"', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
SHORT[BACKSLASH] = BACKSLASH;
const NUMBER = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/;

function parseJson(text, { protoSafe = true } = {}) {
  let i = 0;

  const fail = (message) => {
    const err = new Error(message + ' at position ' + i);
    err.name = 'SyntaxError';
    err.position = i;
    throw err;
  };
  const skipSpace = () => {
    while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n' || text[i] === '\r')) i++;
  };
  const expect = (c) => { if (text[i] !== c) fail('expected ' + JSON.stringify(c)); i++; };

  const string = () => {
    expect('"');
    let out = '';
    for (;;) {
      if (i >= text.length) fail('unterminated string');
      const c = text[i];
      if (c === '"') { i++; return out; }
      if (c.charCodeAt(0) < 32) fail('raw control character in string');
      if (c !== BACKSLASH) { out += c; i++; continue; }
      i++;
      const kind = text[i];
      if (kind === 'u') {
        const hex = text.slice(i + 1, i + 5);
        if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('bad unicode escape');
        out += String.fromCharCode(parseInt(hex, 16));
        i += 5;
        continue;
      }
      if (!Object.prototype.hasOwnProperty.call(SHORT, kind)) fail('unknown escape');
      out += SHORT[kind];
      i++;
    }
  };

  const value = () => {
    skipSpace();
    const c = text[i];
    if (c === '{') {
      i++;
      const out = {};
      skipSpace();
      if (text[i] === '}') { i++; return out; }
      for (;;) {
        skipSpace();
        const key = string();
        skipSpace();
        expect(':');
        const parsed = value();
        if (protoSafe) {
          Object.defineProperty(out, key, { value: parsed, writable: true, enumerable: true, configurable: true });
        } else {
          out[key] = parsed;                 // wrong for exactly one key
        }
        skipSpace();
        if (text[i] === ',') { i++; continue; }
        expect('}');
        return out;
      }
    }
    if (c === '[') {
      i++;
      const out = [];
      skipSpace();
      if (text[i] === ']') { i++; return out; }
      for (;;) {
        out.push(value());
        skipSpace();
        if (text[i] === ',') { i++; continue; }
        expect(']');
        return out;
      }
    }
    if (c === '"') return string();
    if (text.startsWith('true', i)) { i += 4; return true; }
    if (text.startsWith('false', i)) { i += 5; return false; }
    if (text.startsWith('null', i)) { i += 4; return null; }
    const match = NUMBER.exec(text.slice(i));
    if (match) { i += match[0].length; return Number(match[0]); }
    return fail('unexpected ' + (i >= text.length ? 'end of input' : JSON.stringify(c)));
  };

  const out = value();
  skipSpace();
  if (i < text.length) fail('unexpected trailing ' + JSON.stringify(text[i]));
  return out;
}

const rejects = (fn) => { try { fn(); return false; } catch (err) { return true; } };
const errorOf = (fn) => { try { fn(); return 'no error'; } catch (err) { return err.message; } };

const receipt = '{ "till": "Balogun 1", "items": [ { "sku": "ADR-1", "kobo": 450000 }, ' +
  '{ "sku": "ADR-2", "kobo": 125000 } ], "paid": true, "void": false, "note": null, "vat": 7.5e-2 }';
const mine = parseJson(receipt);
const theirs = JSON.parse(receipt);
const matchesTheRealThing = [same(mine, theirs), mine.items[1].kobo, mine.vat, mine.note, mine.void];

// A string with every awkward escape in it, built without drowning in backslashes.
const escaped = '"Adaeze' + BACKSLASH + 'nsaid ' + BACKSLASH + '"eleven' + BACKSLASH + '" ' +
  BACKSLASH + 'u20a6450 ' + BACKSLASH + BACKSLASH + ' end"';
const decoded = parseJson(escaped);
const escapeBehaviour = [decoded === JSON.parse(escaped), decoded.split('\n').length, decoded.indexOf('\u20a6450') !== -1, decoded.indexOf(BACKSLASH) !== -1];

const numbers = '[0, -0.5, 12, 1e3, -2.5E+2, 1e-3]';
const numbersMatch = [same(parseJson(numbers), JSON.parse(numbers)), parseJson(numbers)];

const duplicated = '{"kobo": 100, "kobo": 250}';
const duplicateKeys = [parseJson(duplicated).kobo, JSON.parse(duplicated).kobo, Object.keys(parseJson(duplicated)).length];

const bad = ['{"a": 1,}', "{'a': 1}", '{"a": NaN}', '[01]', '[+1]', '[.5]', '{"a": 1} extra', '', '   ', '[1, 2'];
const bothReject = bad.map((text) => [rejects(() => parseJson(text)), rejects(() => JSON.parse(text))]);
const everyOneRejectedByBoth = bothReject.every(([a, b]) => a === true && b === true);

const positioned = errorOf(() => parseJson('{"a": 1, "b": tru}'));

const polluting = '{"__proto__": {"admin": true}, "user": "Adaeze"}';
const safe = parseJson(polluting);
const naive = parseJson(polluting, { protoSafe: false });
const real = JSON.parse(polluting);
const protoStory = [
  Object.keys(safe),
  Object.keys(naive),
  same(Object.keys(safe), Object.keys(real)),
  naive.admin === true,
  safe.admin,
];

const huge = '{"n": 9007199254740993}';
const precisionLoss = [parseJson(huge).n === JSON.parse(huge).n, parseJson(huge).n === 9007199254740992];

console.log('receipt matches :', matchesTheRealThing);
console.log('escapes         :', JSON.stringify(decoded));
console.log('bad input       :', everyOneRejectedByBoth, 'for', bad.length, 'inputs');
console.log('__proto__       :', protoStory);

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 whole receipt parses to exactly what JSON.parse produces', matchesTheRealThing, [true, 125000, 0.075, null, false]);
test('every short escape and a \\u escape decode the same way the real parser decodes them', escapeBehaviour, [true, 2, true, true]);
test('the number grammar handles fractions and exponents identically', numbersMatch, [true, [0, -0.5, 12, 1000, -250, 0.001]]);
test('a duplicate key is not an error: the last one wins, in both parsers', duplicateKeys, [250, 250, 1]);
test('all ten malformed inputs are rejected, and rejected by JSON.parse too', [everyOneRejectedByBoth, bothReject.length], [true, 10]);
test('an error says what it choked on and where', positioned, 'unexpected "t" at position 14');
test('__proto__ must be defined, not assigned, or the key vanishes into the prototype', protoStory, [['__proto__', 'user'], ['user'], true, true, undefined]);
test('and a 17-digit integer loses precision in exactly the same way', precisionLoss, [true, true]);

The __proto__ assertion is the one to take away. out[key] = parsed looks like the obvious way to build an object, and for one key in the language it does something else entirely: assigning to __proto__ invokes the inherited setter and changes the object's prototype instead of creating a property. The naive parse loses the key from Object.keys and gains an inherited admin that reads as true — that is prototype pollution, and it is a real vulnerability class, not a curiosity. Object.defineProperty defines an own property and matches what JSON.parse does, which is why the real parser is safe here while a hand-rolled one often is not.

JSON's number grammar is narrower than JavaScript's, and that is the second thing people get wrong by reaching for parseFloat. 01, +1, .5, 1., Infinity and NaN are all illegal JSON, and parseFloat happily accepts most of them — worse, it accepts a valid prefix of an invalid token and reports no error. An anchored regex that matches the grammar and nothing else, with Number for the conversion, is the honest version.

Two shared behaviours are worth knowing rather than guessing. A duplicate key is not an error in JSON: the specification permits it and every mainstream parser keeps the last one, so a document with two kobo keys is accepted and one value silently disappears. And 9007199254740993 parses to 9007199254740992, because JSON has no integer type and the value goes through a double; an API returning 64-bit identifiers as numbers is corrupting them before your code ever sees them, which is why those identifiers arrive as strings in any API that has been bitten.

117 Money in kobo, and a split that loses nothing

hard

Never store money in a float. Build an immutable Money type holding whole kobo, with plus, minus, times, a splitInto(n) that hands the remainder out rather than dropping it, an allocate for uneven shares, and a format that groups thousands. Prove the arithmetic is exact where floats are not, and prove the property that matters most: the parts of a split always add back up to the whole, for any amount and any number of parts, positive or negative. Then look closely at how you round, because Math.round is not symmetric about zero.

Stuck? Three hints, smallest first
  1. 1. One integer field. Every operation returns a new Money and the constructor refuses anything that is not a whole number, so a fraction of a kobo can never get in.
  2. 2. To split, take the floor share for everybody and then hand out the leftover kobo one at a time. The remainder is always smaller than the number of parts, so somebody gets at most one extra.
  3. 3. Math.round(-2.5) is -2, not -3: it rounds towards positive infinity on a tie. For money you want ties to go away from zero, so a refund rounds like a charge.

The answer

const SYMBOLS = { NGN: '\u20a6', USD: '$' };
const roundHalfAway = (n) => (n < 0 ? -Math.round(-n) : Math.round(n));

class Money {
  constructor(kobo, currency = 'NGN') {
    if (!Number.isInteger(kobo)) {
      throw new TypeError('kobo must be a whole number, got ' + kobo);
    }
    if (!SYMBOLS[currency]) throw new RangeError('unknown currency ' + currency);
    this.kobo = kobo;
    this.currency = currency;
    Object.freeze(this);
  }

  static fromNaira(naira, currency = 'NGN') {
    return new Money(roundHalfAway(naira * 100), currency);
  }

  sameCurrency(other) {
    if (other.currency !== this.currency) {
      throw new TypeError('cannot mix ' + this.currency + ' and ' + other.currency);
    }
  }

  plus(other) { this.sameCurrency(other); return new Money(this.kobo + other.kobo, this.currency); }
  minus(other) { this.sameCurrency(other); return new Money(this.kobo - other.kobo, this.currency); }
  times(factor) { return new Money(roundHalfAway(this.kobo * factor), this.currency); }

  splitInto(parts) {
    if (!Number.isInteger(parts) || parts < 1) throw new RangeError('parts must be a positive whole number');
    const base = Math.trunc(this.kobo / parts);
    let left = this.kobo - base * parts;              // same sign as the amount
    const step = left < 0 ? -1 : 1;
    const out = [];
    for (let i = 0; i < parts; i++) {
      const extra = left === 0 ? 0 : step;
      left -= extra;
      out.push(new Money(base + extra, this.currency));
    }
    return out;
  }

  allocate(ratios) {
    if (!ratios.length) throw new RangeError('need at least one ratio');
    const whole = ratios.reduce((a, b) => a + b, 0);
    let left = this.kobo;
    const shares = ratios.map((r) => {
      const share = Math.trunc((this.kobo * r) / whole);
      left -= share;
      return share;
    });
    for (let i = 0; left !== 0; i = (i + 1) % shares.length) {
      const step = left < 0 ? -1 : 1;
      shares[i] += step;
      left -= step;
    }
    return shares.map((k) => new Money(k, this.currency));
  }

  format() {
    const sign = this.kobo < 0 ? '-' : '';
    const size = Math.abs(this.kobo);
    const naira = String(Math.trunc(size / 100)).replace(/\B(?=([0-9]{3})+(?![0-9]))/g, ',');
    const kobo = String(size % 100).padStart(2, '0');
    return sign + SYMBOLS[this.currency] + naira + '.' + kobo;
  }

  toJSON() { return { kobo: this.kobo, currency: this.currency }; }
}

const errorOf = (fn) => { try { fn(); return 'no error'; } catch (err) { return err.name + ': ' + err.message; } };
const kobos = (list) => list.map((m) => m.kobo);
const sumOf = (list) => list.reduce((a, m) => a + m.kobo, 0);

const tenKobo = new Money(10);
const twentyKobo = new Money(20);
const floatVersus = [0.1 + 0.2 === 0.3, tenKobo.plus(twentyKobo).kobo];

let floatTotal = 0;
let koboTotal = new Money(0);
for (let i = 0; i < 10; i++) { floatTotal += 0.1; koboTotal = koboTotal.plus(tenKobo); }
const tenAdditions = [floatTotal === 1, koboTotal.kobo, koboTotal.format()];

const tenNaira = Money.fromNaira(10);
const threeWays = tenNaira.splitInto(3);
const splitStory = [kobos(threeWays), sumOf(threeWays), sumOf(threeWays) === tenNaira.kobo];

const refund = new Money(-1000);
const refundSplit = refund.splitInto(3);
const refundStory = [kobos(refundSplit), sumOf(refundSplit)];

// The property, over every amount and every number of parts in a range.
let nothingEverLost = true;
for (let amount = -37; amount <= 37; amount++) {
  for (let parts = 1; parts <= 7; parts++) {
    const pieces = new Money(amount).splitInto(parts);
    if (sumOf(pieces) !== amount) nothingEverLost = false;
    if (pieces.length !== parts) nothingEverLost = false;
  }
}

const rent = Money.fromNaira(1000);
const uneven = rent.allocate([3, 2, 1]);
const allocateStory = [kobos(uneven), sumOf(uneven) === rent.kobo];

const formatting = [
  Money.fromNaira(1234567.05).format(),
  new Money(-4550).format(),
  new Money(7).format(),
  new Money(0).format(),
  new Money(199, 'USD').format(),
];

const roundingTrap = [Math.round(-2.5), Math.round(2.5), roundHalfAway(-2.5), roundHalfAway(2.5)];
const vat = [new Money(45000).times(0.075).kobo, new Money(1).times(0.075).kobo, new Money(-45000).times(0.075).kobo];

const refusals = [
  errorOf(() => new Money(10.5)),
  errorOf(() => new Money(100).plus(new Money(100, 'USD'))),
  errorOf(() => new Money(100).splitInto(0)),
];
const frozen = errorOf(() => { tenKobo.kobo = 9999; });
const projected = [JSON.parse(JSON.stringify(new Money(45000))), Object.isFrozen(tenKobo)];

console.log('floats vs kobo :', floatVersus, tenAdditions);
console.log('split of 1000  :', splitStory);
console.log('allocate 3:2:1 :', allocateStory);
console.log('formatted      :', formatting.join('  '));

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('0.1 + 0.2 is not 0.3, while 10 kobo plus 20 kobo is exactly 30', floatVersus, [false, 30]);
test('and ten additions land on a whole naira in kobo but not in floats', tenAdditions, [false, 100, '\u20a61.00']);
test('splitting 1000 kobo three ways gives the leftover kobo away, not up', splitStory, [[334, 333, 333], 1000, true]);
test('a refund splits the same way, with the extra kobo on the same side of zero', refundStory, [[-334, -333, -333], -1000]);
test('for every amount from -37 to 37 and every part count to 7, the pieces add back to the whole', nothingEverLost, true);
test('an uneven 3:2:1 allocation of 100000 kobo also loses nothing', allocateStory, [[50001, 33333, 16666], true]);
test('formatting groups thousands, pads kobo, and puts the sign before the symbol', formatting, ['\u20a61,234,567.05', '-\u20a645.50', '\u20a60.07', '\u20a60.00', '$1.99']);
test('Math.round is not symmetric about zero, so ties are rounded away from it instead', [roundingTrap, vat], [[-2, 3, -3, 3], [3375, 0, -3375]]);
test('a fraction of a kobo, a mixed currency and a zero split are all refused, and the value is frozen', [refusals, frozen, projected], [['TypeError: kobo must be a whole number, got 10.5', 'TypeError: cannot mix NGN and USD', 'RangeError: parts must be a positive whole number'], 'TypeError: Cannot assign to read only property \'kobo\' of object \'#<Money>\'', [{ kobo: 45000, currency: 'NGN' }, true]]);

Money in a float is wrong before you do anything with it, because 0.1 has no exact binary representation. The first two assertions are that in two forms: 0.1 + 0.2 !== 0.3, and ten additions of ten kobo land on exactly ₦1.00 while ten additions of 0.1 do not land on 1. Storing the smallest unit as an integer removes the whole class of problem — every amount is exact, every comparison is exact, and the error can only enter at the two places you choose to round.

The fifth assertion is the one worth writing for real code. Rather than checking a handful of splits, it checks the property — the pieces add back up to the whole, and there are as many pieces as asked for — over 75 amounts and 7 part counts, negatives included. That is what catches the two classic bugs at once: dividing and rounding each share, which loses kobo, and giving the remainder to one person as a lump, which is right in total and wrong per person. Handing the leftover out one kobo at a time is the only version that is both exact and fair, and for a payments system the exactness is auditable: somebody will reconcile it.

Math.round is the sharp edge nobody expects. It rounds a tie towards positive infinity, so Math.round(2.5) is 3 and Math.round(-2.5) is -2 — the two are not mirror images. Applied to money that means a charge of ₦0.025 rounds up and a refund of the same amount rounds down, so charging and refunding the same thing does not return you to zero. roundHalfAway makes the operation symmetric, and the eighth assertion holds it to that. Note too that VAT on a single kobo rounds to zero: rounding to the smallest unit always loses something, so the honest design is to choose where that happens rather than let it happen everywhere.

Two smaller points. Object.freeze in the constructor plus operations that return new instances makes the value type behave like a number, and under the strict mode these panels run in an accidental write throws instead of silently corrupting a balance. And toJSON is what lets the amount cross a wire without a currency going missing — a bare number in an API response is an amount whose unit you have to guess.

118 Validation that reports every problem, with the path to each

hard

Finish the set by building a validation library out of nothing but functions. A validator takes a value and a path and returns either { ok: true, value } or { ok: false, errors: [{ path, message }] }. Write the leaves — string, whole number, one-of — and the combinators that build structures out of them: all for chaining rules on one field, optional, arrayOf and shape. The point is the reporting: one bad request must come back with every problem in it, each carrying a path like items[1].kobo, so a form can highlight all of its fields at once. Then be deliberate about where you accumulate and where you stop.

Stuck? Three hints, smallest first
  1. 1. A validator is just (value, path) => result. Every combinator takes validators and returns one, so nothing needs a class or a registry.
  2. 2. shape and arrayOf collect errors from every field and every element before returning. all is the opposite: it stops at the first failure, because a length check on a value that is not a string is meaningless.
  3. 3. Build the path as you descend: path + '.' + key for a field, path + '[' + index + ']' for an element. Pass it down, never reconstruct it on the way back up.

The answer

const kindOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
const pass = (value) => ({ ok: true, value });
const failAt = (path, message) => ({ ok: false, errors: [{ path, message }] });

const isString = () => (value, path) =>
  typeof value === 'string' ? pass(value) : failAt(path, 'expected a string, got ' + kindOf(value));
const isWholeNumber = () => (value, path) =>
  Number.isInteger(value) ? pass(value) : failAt(path, 'expected a whole number, got ' + kindOf(value));
const atLeast = (n) => (value, path) =>
  value >= n ? pass(value) : failAt(path, 'must be at least ' + n);
const longerThan = (n) => (value, path) =>
  value.length > n ? pass(value) : failAt(path, 'must be longer than ' + n + ' characters');
const oneOf = (allowed) => (value, path) =>
  allowed.indexOf(value) !== -1 ? pass(value) : failAt(path, 'must be one of ' + allowed.join(', '));

// Chaining STOPS at the first failure: later rules assume the earlier ones held.
const all = (...checks) => (value, path) => {
  let current = value;
  for (const check of checks) {
    const result = check(current, path);
    if (!result.ok) return result;
    current = result.value;
  }
  return pass(current);
};

const optional = (check) => (value, path) => (value === undefined ? pass(undefined) : check(value, path));

// Structures ACCUMULATE: every element is checked before anything is returned.
const arrayOf = (check) => (value, path) => {
  if (!Array.isArray(value)) return failAt(path, 'expected an array, got ' + kindOf(value));
  const out = [];
  const errors = [];
  value.forEach((item, index) => {
    const result = check(item, path + '[' + index + ']');
    if (result.ok) out.push(result.value);
    else for (const e of result.errors) errors.push(e);
  });
  return errors.length ? { ok: false, errors } : pass(out);
};

const shape = (fields, { allowUnknown = false } = {}) => (value, path) => {
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
    return failAt(path, 'expected an object, got ' + kindOf(value));
  }
  const out = {};
  const errors = [];
  for (const key of Object.keys(fields)) {
    const at = path ? path + '.' + key : key;
    const result = fields[key](value[key], at);
    if (!result.ok) { for (const e of result.errors) errors.push(e); continue; }
    if (result.value !== undefined) out[key] = result.value;
  }
  if (!allowUnknown) {
    for (const key of Object.keys(value)) {
      if (!Object.prototype.hasOwnProperty.call(fields, key)) {
        errors.push({ path: path ? path + '.' + key : key, message: 'unknown key' });
      }
    }
  }
  return errors.length ? { ok: false, errors } : pass(out);
};

const lineItem = shape({
  sku: all(isString(), longerThan(2)),
  kobo: all(isWholeNumber(), atLeast(1)),
});

const order = shape({
  till: all(isString(), longerThan(2)),
  currency: oneOf(['NGN', 'USD']),
  note: optional(all(isString(), longerThan(0))),
  items: arrayOf(lineItem),
});

const report = (result) => (result.ok ? 'ok' : result.errors.map((e) => e.path + ': ' + e.message));

const good = order({
  till: 'Balogun 1',
  currency: 'NGN',
  items: [{ sku: 'ADR-1', kobo: 450000 }, { sku: 'ADR-2', kobo: 125000 }],
}, '');
const goodStory = [good.ok, good.value, 'note' in good.value];

const messy = order({
  till: 'B',
  currency: 'GBP',
  items: [{ sku: 'ADR-1', kobo: 4.5 }, { kobo: 0 }, 'not an object'],
  discount: 500,
}, '');
const everyProblem = report(messy);

const chainStops = report(order({ till: 42, currency: 'NGN', items: [] }, ''));

const optionalStory = [
  report(order({ till: 'Ikeja 1', currency: 'NGN', note: 'rush', items: [] }, '')),
  report(order({ till: 'Ikeja 1', currency: 'NGN', note: '', items: [] }, '')),
  report(order({ till: 'Ikeja 1', currency: 'NGN', note: 7, items: [] }, '')),
];

const unknownKeys = [
  report(order({ till: 'Ikeja 1', currency: 'NGN', items: [], extra: 1 }, '')),
  report(shape({ till: isString() }, { allowUnknown: true })({ till: 'Ikeja 1', extra: 1 }, '')),
];

const notAnArray = report(order({ till: 'Ikeja 1', currency: 'NGN', items: { 0: 'x' } }, ''));
const nullVsMissing = [
  report(order({ till: null, currency: 'NGN', items: [] }, '')),
  report(order({ currency: 'NGN', items: [] }, '')),
];

// A new rule is just another function. Nothing above it changes.
const divisibleBy = (n) => (value, path) =>
  value % n === 0 ? pass(value) : failAt(path, 'must be a multiple of ' + n);
const roundKobo = shape({ kobo: all(isWholeNumber(), atLeast(1), divisibleBy(50)) });
const customRule = [report(roundKobo({ kobo: 450000 }, '')), report(roundKobo({ kobo: 12 }, ''))];

const deep = shape({ batches: arrayOf(shape({ items: arrayOf(lineItem) })) });
const deepReport = report(deep({
  batches: [
    { items: [{ sku: 'ADR-1', kobo: 100 }] },
    { items: [{ sku: 'ADR-2', kobo: 100 }, { sku: 'X', kobo: 100 }] },
  ],
}, ''));

console.log('good order   :', goodStory);
console.log('every problem:', everyProblem);
console.log('deep path    :', deepReport);

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 valid order passes and hands back the value it validated', goodStory, [true, { till: 'Balogun 1', currency: 'NGN', items: [{ sku: 'ADR-1', kobo: 450000 }, { sku: 'ADR-2', kobo: 125000 }] }, false]);
test('one bad order reports every problem at once, each with a path into the structure', everyProblem, ['till: must be longer than 2 characters', 'currency: must be one of NGN, USD', 'items[0].kobo: expected a whole number, got number', 'items[1].sku: expected a string, got undefined', 'items[1].kobo: must be at least 1', 'items[2]: expected an object, got string', 'discount: unknown key']);
test('within one field the chain stops at the first failure: one message, not two', chainStops, ['till: expected a string, got number']);
test('an optional field may be absent, but not present and wrong', optionalStory, ['ok', ['note: must be longer than 0 characters'], ['note: expected a string, got number']]);
test('an unknown key is reported at its own path unless the shape allows it', unknownKeys, [['extra: unknown key'], 'ok']);
test('an object where an array belongs is reported once, without being indexed', notAnArray, ['items: expected an array, got object']);
test('null is a value that fails its check, while missing reports the type it lacked', nullVsMissing, [['till: expected a string, got null'], ['till: expected a string, got undefined']]);
test('a brand new rule composes into the existing chain with nothing else changed', customRule, ['ok', ['kobo: must be a multiple of 50']]);
test('and a path is built on the way down, so three levels deep still reads plainly', deepReport, ['batches[1].items[1].sku: must be longer than 2 characters']);

This is combinator design, and the whole library is functions returning functions. There is no base class, no schema object and no registry, because a validator is nothing but (value, path) => result — so arrayOf(shape({ ... })) composes for the same reason map and filter compose. The eighth assertion is the payoff: divisibleBy is four lines written outside the library and it drops into an existing chain with nothing above it modified. That is the test of whether an abstraction is actually open.

The second and third assertions are two opposite decisions, and getting them the wrong way round is what makes validation libraries annoying. shape and arrayOf accumulate: a form that highlights one field, waits for a fix, then highlights the next is a bad form, so every field and every element is checked before anything is returned. all short-circuits: once till is known not to be a string, running a length check on it would either crash or produce a second message about a problem the user does not have. Accumulate across siblings, stop within a chain.

The path is the other half of a usable error, and it only works if it is built on the way down. Each combinator appends its own segment before recursing — '.' + key for a field, '[' + index + ']' for an element — so a leaf that fails already knows its full address and returns batches[1].items[1].sku without anything reassembling it on the way back. Try to reconstruct the path while unwinding and every combinator has to know about every other one.

Two distinctions the assertions pin down because they are easy to blur. null is a value and fails its check, while a missing key arrives as undefined and is reported as such — the messages differ, and a user needs them to. And an unknown key is a failure by default rather than a silent drop: an API that ignores discont because it expected discount takes the money and applies nothing, which is the kind of bug that is only ever found by a customer.

Newsletter

New writing, when it lands.

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