Emmanuel Akinfulubi

JavaScript: Fundamental (ES6), part 2, 69–97

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

Exercises 69 to 97 of 118

69 Symbol.for and the global registry

fair

Symbol() gives a key nobody else can produce. Symbol.for() gives one everybody can. Show that two Symbol.for calls with the same string are the same key, that a value stored under one is readable by code that never saw it, that Symbol.keyFor distinguishes the two kinds, and that the well-known symbols are not in the registry at all.

Stuck? Three hints, smallest first
  1. 1. Symbol.for(key) looks in a process-wide table and only creates a symbol if the key is new.
  2. 2. Symbol.keyFor answers undefined for anything that did not come from the registry.
  3. 3. A description is a label. Two symbols can share one and still be different keys.

The answer

const registered = Symbol.for('gradsphere.audit');
const again = Symbol.for('gradsphere.audit');
const local = Symbol('gradsphere.audit');

const sameKey = registered === again;
const differentKey = registered === local;
const keys = [Symbol.keyFor(registered), Symbol.keyFor(local)];

const state = {};
state[registered] = 'Balogun till 3';
state[local] = 'Ikeja till 1';

// Code over here never saw the symbols above, and only one of the two is reachable.
const reachable = state[Symbol.for('gradsphere.audit')];
const unreachable = state[Symbol('gradsphere.audit')] === undefined;
const bothStored = Object.getOwnPropertySymbols(state).length;

const printIdentically = [String(registered), String(local)];
const descriptions = [registered.description, local.description];

const wellKnown = [Symbol.keyFor(Symbol.iterator), Symbol.keyFor(Symbol.toStringTag)];
const iteratorIsGlobalAnyway = Symbol.iterator === Symbol.iterator;

const flatNamespace = Symbol.for('audit') === Symbol.for('gradsphere.audit');
const emptyKeyWorks = Symbol.keyFor(Symbol.for(''));

console.log('same key, different key:', sameKey, differentKey);
console.log('keyFor                 :', keys);
console.log('reachable from nowhere :', reachable);
console.log('both print as          :', printIdentically);

Output


      
9 checks that prove this answer

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

9 checks

test('two Symbol.for calls with the same string are one symbol', sameKey, true);
test('a Symbol() with the same description is a different key entirely', differentKey, false);
test('keyFor round-trips a registry symbol and refuses a local one', keys, ['gradsphere.audit', undefined]);
test('so a registry key reaches the value without the symbol being shared', reachable, 'Balogun till 3');
test('while the local key is unreachable, though the value is still there', [unreachable, bothStored], [true, 2]);
test('both print identically and share a description: text cannot tell them apart', [printIdentically, descriptions], [['Symbol(gradsphere.audit)', 'Symbol(gradsphere.audit)'], ['gradsphere.audit', 'gradsphere.audit']]);
test('the well-known symbols are global but not in the registry', [wellKnown, iteratorIsGlobalAnyway], [[undefined, undefined], true]);
test('the registry is one flat namespace, which is why real keys are prefixed', flatNamespace, false);
test('and even an empty string is a valid registry key', emptyKeyWorks, '');

Two mechanisms that look alike and mean opposite things. Symbol('x') is for a key you own: nobody can collide with it, and nobody can reach your value without the symbol itself. Symbol.for('x') is for a key you want others to reach — a protocol. The fourth and fifth assertions are that difference in one object: both values are on state, and only the registered one can be looked up from code that never saw the symbol.

The registry is per realm-group, not per module, which is the point: two copies of a library bundled twice, or a library and a page script, agree on Symbol.for('react.element') without importing anything from each other. That is why the last-but-two assertion matters — one flat namespace, no scoping, so a bare key like 'audit' is a collision waiting to happen. Prefix with something you own.

Two things people expect and do not get. Registry symbols are never collected: the table holds them for the life of the realm, so Symbol.for in a loop over user input is a leak. And the well-known symbols — Symbol.iterator and friends — are not registry entries, so Symbol.keyFor(Symbol.iterator) is undefined even though the symbol is shared by everything; they are their own separate kind of global.

The sixth assertion is the one to keep in mind while debugging. Two symbols with the same description print the same, log the same and describe the same, and are still different keys. If a symbol-keyed lookup is missing a value, comparing the text tells you nothing.

70 Symbol.species decides what map gives back

hard

Subclass Array and check what filter returns. Then set Symbol.species to Array and check again. Find out what the species constructor is called with, and what that does to a subclass whose own first argument is not a length.

Stuck? Three hints, smallest first
  1. 1. Array methods that build a new array ask the instance for a constructor rather than using Array.
  2. 2. static get [Symbol.species]() { return Array; } is the whole opt-out.
  3. 3. The species constructor is invoked with the new length. A subclass expecting a name receives a number.

The answer

class Fares extends Array {
  totalNaira() { return this.reduce((sum, fare) => sum + fare, 0); }
}
const fares = new Fares();
fares.push(300, 500, 250);

const filtered = fares.filter((fare) => fare >= 300);
const keepsSubclass = [filtered instanceof Fares, Array.isArray(filtered), filtered.totalNaira()];

class PlainResults extends Array {
  static get [Symbol.species]() { return Array; }
  totalNaira() { return this.reduce((sum, fare) => sum + fare, 0); }
}
const plain = new PlainResults();
plain.push(300, 500, 250);
const mapped = plain.map((fare) => fare * 2);
const nowPlain = [mapped instanceof PlainResults, Array.isArray(mapped), typeof mapped.totalNaira];
const originalKeepsIt = plain.totalNaira();

// What the species constructor is called with: the new length.
class Ledger extends Array {
  constructor(till) { super(); this.till = till; }
}
const ledger = new Ledger('Balogun 3');
ledger.push(300, 500);
const mappedCopy = ledger.map((fare) => fare);
const filteredCopy = ledger.filter(() => true);
const tillBecameALength = [mappedCopy.till, filteredCopy.till, mappedCopy.length, mappedCopy instanceof Ledger];

class FixedLedger extends Array {
  static get [Symbol.species]() { return Array; }
  constructor(till) { super(); this.till = till; }
}
const fixed = new FixedLedger('Balogun 3');
fixed.push(300, 500);
const fixedCopy = fixed.filter(() => true);
const noStrayTill = [fixedCopy.till === undefined, fixedCopy.length, fixed.till];

const otherMethods = [
  plain.slice(0, 2) instanceof PlainResults,
  plain.concat([100]) instanceof PlainResults,
  fares.slice(0, 2) instanceof Fares,
];

class Receipts extends Promise {}
const chained = Receipts.resolve('LAG-0070').then((ref) => ref);
const promiseSpecies = [chained instanceof Receipts, chained instanceof Promise];

console.log('filter keeps the subclass:', keepsSubclass);
console.log('species Array           :', nowPlain);
console.log('the till became         :', tillBecameALength, '<- map passes the length, filter passes 0');

Output


      
8 checks that prove this answer

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

8 checks

test('by default filter returns the subclass, so its methods survive', keepsSubclass, [true, true, 800]);
test('species set to Array makes map return a plain array', nowPlain, [false, true, 'undefined']);
test('while the original still has its own method', originalKeepsIt, 1050);
test('the species constructor is called with a length, so the till became a number', tillBecameALength, [2, 0, 2, true]);
test('opting out means the subclass constructor is not called at all', noStrayTill, [true, 2, 'Balogun 3']);
test('slice and concat obey species the same way map and filter do', otherMethods, [false, false, true]);
test('Promise uses the same mechanism, so then returns the subclass', promiseSpecies, [true, true]);
test('and Array.isArray is true either way: species changes the class, not the kind', [Array.isArray(mapped), Array.isArray(filtered)], [true, true]);

map, filter, slice, splice, concat, flat and flatMap do not construct an Array. They call ArraySpeciesCreate, which asks the instance's constructor for Symbol.species and uses that. The default is the subclass, which is usually what you want — fares.filter(...) still has totalNaira.

The fourth assertion is why the opt-out exists. ArraySpeciesCreate invokes the constructor with a length, so Ledger, whose first parameter is a till name, was handed a number and stored it as this.till. Which number depends on the method: map passes the source length, so the till became 2, and filter passes 0 because it does not know yet how many elements will survive. No error, no warning either way: the copy is a Ledger with a numeric till. Any subclass of Array whose constructor takes something other than a length has this bug, and the only complete fixes are to declare Symbol.species as Array or to stop extending Array.

The general lesson is worth more than the mechanism. Extending a built-in means every method on it becomes a constructor call you did not write, and the built-in decides the arguments. Composition — a class with an array field — has no such surprises, which is why most codebases stopped subclassing Array.

Promise, RegExp, typed arrays and ArrayBuffer all use species too, so then on a promise subclass builds another instance of the subclass. Same rule, same trap: whatever your constructor requires, the built-in will call it with the arguments the specification chose.

71 defineProperty defaults to false three times

fair

Add one property by assignment and one with Object.defineProperty and no attributes. Read both descriptors and account for every difference. Then show what the defaults cost: writing, deleting, redefining, Object.keys and JSON.stringify. Finish with a partial redefinition of an existing property.

Stuck? Three hints, smallest first
  1. 1. An assignment creates a property that is writable, enumerable and configurable. defineProperty defaults every one of those to false.
  2. 2. Not enumerable means Object.keys, for...in, spread and JSON.stringify all skip it.
  3. 3. Redefining an existing property only changes the attributes you name.

The answer

const attemptName = (fn) => { try { fn(); return 'no error'; } catch (err) { return err.name; } };
const flags = (obj, key) => {
  const d = Object.getOwnPropertyDescriptor(obj, key);
  return [d.writable, d.enumerable, d.configurable];
};

const order = {};
order.customer = 'Bisi';                                   // by assignment
Object.defineProperty(order, 'ref', { value: 'LAG-0071' }); // by descriptor, no attributes

const byAssignment = flags(order, 'customer');
const byDescriptor = flags(order, 'ref');

const visible = [Object.keys(order), JSON.stringify(order), { ...order }];
const enumerated = [];
for (const key in order) enumerated.push(key);

const readsFine = order.ref;
const foundAnyway = ['ref' in order, Object.getOwnPropertyNames(order), Object.hasOwn(order, 'ref')];

const writeRefused = attemptName(() => { order.ref = 'LAG-9999'; });
const deleteRefused = attemptName(() => { delete order.ref; });
const redefineRefused = attemptName(() => Object.defineProperty(order, 'ref', { value: 'LAG-9999' }));
const unchanged = order.ref;

// Redefining an existing property: unnamed attributes are left as they were.
Object.defineProperty(order, 'customer', { enumerable: false });
const afterPartial = flags(order, 'customer');
order.customer = 'Adaeze';                                  // still writable
const stillWritable = order.customer;
const nowHidden = Object.keys(order);

console.log('assignment vs descriptor:', byAssignment, byDescriptor);
console.log('visible                 :', visible);
console.log('refusals                :', [writeRefused, deleteRefused, redefineRefused]);

Output


      
8 checks that prove this answer

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

8 checks

test('an assignment gives writable, enumerable and configurable', byAssignment, [true, true, true]);
test('defineProperty with no attributes gives none of the three', byDescriptor, [false, false, false]);
test('so keys, JSON and spread all skip it, and for...in too', [visible, enumerated], [[['customer'], '{"customer":"Bisi"}', { customer: 'Bisi' }], ['customer']]);
test('it is still readable, and the own-property checks all find it', [readsFine, foundAnyway], ['LAG-0071', [true, ['customer', 'ref'], true]]);
test('writing, deleting and redefining it all throw in strict mode', [writeRefused, deleteRefused, redefineRefused], ['TypeError', 'TypeError', 'TypeError']);
test('and the value is exactly what it was', unchanged, 'LAG-0071');
test('redefining an existing property leaves unnamed attributes alone', afterPartial, [true, false, true]);
test('so the customer is hidden from keys but still writable', [stillWritable, nowHidden], ['Adaeze', []]);

Object.defineProperty is the low-level operation, and low-level here means "assume nothing". A descriptor is not a patch over sensible defaults — every attribute you omit is false, so { value: x } produces a property that is read-only, invisible and permanent. That is almost never what someone means the first time they use it, and the resulting bug is a value that silently refuses to change.

Silently is the wrong word inside a module, a class body or one of these panels, because those are all strict, and a refused write is a TypeError. In sloppy mode the same assignment does nothing at all and returns the value you assigned, so order.ref = 'x' evaluates to 'x' while order.ref is unchanged — the version of this bug that takes an afternoon to find.

The last two assertions are the rule that saves you. For a property that already exists, only the attributes you name are touched, so { enumerable: false } hides a key without making it read-only. That is how you keep a field out of JSON.stringify and Object.keys while ordinary code carries on assigning to it — cheaper than a symbol key and visible to getOwnPropertyNames when you are debugging.

Note which checks still find a non-enumerable property: in, Object.hasOwn, getOwnPropertyNames and Reflect.ownKeys. Enumerability governs listing and copying, not existence.

72 The same getter, three enumerabilities

hard

Define the same computed total three ways: in an object literal, with Object.defineProperty, and as a class getter. Read all three descriptors, then check which of them Object.keys, JSON.stringify and spread can see. Then find out what spread does to a getter.

Stuck? Three hints, smallest first
  1. 1. A getter in a literal is enumerable and configurable. The same getter through defineProperty defaults to neither.
  2. 2. A class getter lives on the prototype, and nothing on a prototype is an own property of the instance.
  3. 3. Spread copies values. Ask what it does with an accessor.

The answer

const literal = {
  fare: 400,
  get total() { return this.fare + 50; },
};

const defined = { fare: 400 };
Object.defineProperty(defined, 'total', { get() { return this.fare + 50; } });

class Trip {
  constructor(fare) { this.fare = fare; }
  get total() { return this.fare + 50; }
}
const instance = new Trip(400);

const shape = (obj, key) => {
  const d = Object.getOwnPropertyDescriptor(obj, key);
  return d ? [typeof d.get, d.enumerable, d.configurable] : 'no own property';
};

const literalShape = shape(literal, 'total');
const definedShape = shape(defined, 'total');
const instanceShape = shape(instance, 'total');
const prototypeShape = shape(Trip.prototype, 'total');

const allThreeRead = [literal.total, defined.total, instance.total];
const literalVisible = [Object.keys(literal), JSON.stringify(literal)];
const definedVisible = [Object.keys(defined), JSON.stringify(defined)];
const instanceVisible = [Object.keys(instance), JSON.stringify(instance)];

const spreadLiteral = { ...literal };
const spreadShape = shape(spreadLiteral, 'total');
literal.fare = 900;
const gettersFollow = [literal.total, defined.total];
const snapshotDoesNot = spreadLiteral.total;

console.log('descriptors:', literalShape, definedShape, instanceShape);
console.log('literal    :', literalVisible);
console.log('defined    :', definedVisible);
console.log('instance   :', instanceVisible);

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 literal getter is an own accessor, enumerable and configurable', literalShape, ['function', true, true]);
test('the same getter via defineProperty defaults to neither', definedShape, ['function', false, false]);
test('a class getter is on the prototype, so the instance owns nothing', [instanceShape, prototypeShape], ['no own property', ['function', false, true]]);
test('all three read identically, which is what hides the difference', allThreeRead, [450, 450, 450]);
test('so only the literal getter is listed and serialised', [literalVisible, definedVisible, instanceVisible], [[['fare', 'total'], '{"fare":400,"total":450}'], [['fare'], '{"fare":400}'], [['fare'], '{"fare":400}']]);
test('spread replaces the accessor with a plain data property', spreadShape, ['undefined', true, true]);
test('so the getters follow a later change to the fare', gettersFollow, [950, 450]);
test('while the spread copy is a snapshot, frozen at the moment it was taken', snapshotDoesNot, 450);

Three ways to write one getter, three different answers to "is this part of the object's data". The literal form is enumerable, so Object.keys, for...in, spread, Object.assign and JSON.stringify all include it — a derived value quietly becomes a stored field the moment anything serialises the object, and it comes back from the server as a real property that no longer recomputes.

defineProperty and the class form both keep it out, for different reasons: the first because the defaults are false, the second because prototype properties are never own properties of the instance. The fourth assertion is the reason this is worth an exercise — all three read as 450, so nothing at the call site tells you which kind you have.

The last three assertions are the one that bites in practice. Spread and Object.assign perform a [[Get]] per key, so an accessor becomes a data property holding whatever it returned at that instant. { ...literal } looks like a copy and is a snapshot: change fare and the original's total moves while the copy's does not. To copy an object with its accessors intact you need Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj)), which is exactly what spread is not.

Also note defined.total is non-configurable, so it can never be redefined or deleted, and Trip.prototype.total is configurable, so it can. If you mean a getter to be part of the public data, write it in a literal; if you mean it as behaviour, put it on the prototype or define it explicitly.

73 preventExtensions, seal and freeze

fair

Three levels of lock-down. Apply each to the same object and build the matrix: can you add a key, change one, delete one? Confirm it with isExtensible, isSealed and isFrozen, read the descriptors each one leaves behind, and try push on a frozen array.

Stuck? Three hints, smallest first
  1. 1. One blocks additions. The next also blocks deletions. The last also blocks writes.
  2. 2. Sealing sets configurable: false; freezing also sets writable: false on data properties.
  3. 3. A frozen array has a non-writable length, so push cannot even grow it.

The answer

const attemptName = (fn) => { try { fn(); return 'no error'; } catch (err) { return err.name; } };
const tryAll = (obj) => [
  attemptName(() => { obj.zone = 'Mainland'; }),   // add
  attemptName(() => { obj.fare = 500; }),          // change
  attemptName(() => { delete obj.till; }),         // delete
];

const noAdding = Object.preventExtensions({ fare: 400, till: 'Balogun 3' });
const sealed = Object.seal({ fare: 400, till: 'Balogun 3' });
const frozen = Object.freeze({ fare: 400, till: 'Balogun 3' });

const preventResults = tryAll(noAdding);
const sealResults = tryAll(sealed);
const freezeResults = tryAll(frozen);
const whatSurvived = [noAdding.fare, sealed.fare, frozen.fare];

const flags = (obj) => [Object.isExtensible(obj), Object.isSealed(obj), Object.isFrozen(obj)];
const matrix = [flags(noAdding), flags(sealed), flags(frozen)];

const attrs = (obj) => {
  const d = Object.getOwnPropertyDescriptor(obj, 'fare');
  return [d.writable, d.configurable];
};
const descriptors = [attrs(noAdding), attrs(sealed), attrs(frozen)];

const frozenList = Object.freeze(['Yaba', 'Ikeja']);
const pushRefused = attemptName(() => frozenList.push('Ojota'));
const elementRefused = attemptName(() => { frozenList[0] = 'Ojota'; });
const listUnchanged = frozenList.slice();

const reflectSays = [
  Reflect.set(frozen, 'fare', 500),
  Reflect.deleteProperty(sealed, 'till'),
  Reflect.defineProperty(noAdding, 'zone', { value: 'Mainland' }),
];
const emptyFrozenByDefault = [Object.isFrozen(Object.preventExtensions({})), Object.isFrozen(Object.preventExtensions({ a: 1 }))];

console.log('preventExtensions:', preventResults);
console.log('seal             :', sealResults);
console.log('freeze           :', freezeResults);
console.log('flags            :', matrix);

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('preventExtensions blocks additions only', preventResults, ['TypeError', 'no error', 'no error']);
test('seal blocks additions and deletions, and still allows writes', sealResults, ['TypeError', 'no error', 'TypeError']);
test('freeze blocks all three', freezeResults, ['TypeError', 'TypeError', 'TypeError']);
test('so the write landed on the first two and not on the third', whatSurvived, [500, 500, 400]);
test('the three predicates agree, and frozen implies sealed', matrix, [[false, false, false], [false, true, false], [false, true, true]]);
test('sealing only clears configurable; freezing clears writable as well', descriptors, [[true, true], [true, false], [false, false]]);
test('a frozen array refuses push and element writes alike', [pushRefused, elementRefused, listUnchanged], ['TypeError', 'TypeError', ['Yaba', 'Ikeja']]);
test('Reflect reports each refusal as false instead of throwing', reflectSays, [false, false, false]);
test('and an empty non-extensible object is already sealed and frozen', emptyFrozenByDefault, [true, false]);

Three concentric locks, each adding one prohibition. preventExtensions stops new keys. seal adds "no deletions" by clearing configurable. freeze adds "no writes" by clearing writable as well. The sixth assertion is the mechanism rather than the vocabulary: these are not three separate flags on the object, they are one extensibility bit plus what each function does to the existing descriptors.

The gap people fall into is between seal and freeze. A sealed object still accepts writes to every existing key — the second assertion — so sealing a configuration object protects its shape and nothing about its contents. That is sometimes exactly right: a fixed set of fields whose values change. It is never a substitute for immutability.

The frozen array is worth trying once. push does not fail because arrays are special-cased; it fails because length is now non-writable, and push has to write it. Same for pop, sort, reverse and splice — every mutating method on a frozen array throws in strict mode, and each one throws from inside a built-in, so the stack points at your line and not at the freeze.

The last assertion is a genuine quirk: isFrozen and isSealed ask "is there anything left that could change", so a non-extensible object with no properties is already both. And note Reflect.set, Reflect.deleteProperty and Reflect.defineProperty return false where the operators throw — the same pairing as exercise 64, and the way to test for a lock without a try.

74 Object.freeze is one level deep

hard

Freeze an object with a nested object inside it and write to the nested one. Then write deepFreeze properly: it must handle a cycle, arrays, symbol keys, and it must not run any getter while walking. Finish by finding the one write a freeze cannot stop.

Stuck? Three hints, smallest first
  1. 1. Recurse over Reflect.ownKeys, not Object.keys, or symbol-keyed values stay writable.
  2. 2. A WeakSet of what you have already seen is what stops a cycle recursing for ever.
  3. 3. Read Object.getOwnPropertyDescriptor(obj, key).value. Reading obj[key] invokes accessors.

The answer

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

const shallow = Object.freeze({ till: 'Balogun 3', limits: { daily: 2000000 } });
const topRefused = attemptName(() => { shallow.till = 'Ikeja 1'; });
const nestedAllowed = attemptName(() => { shallow.limits.daily = 0; });
const nestedNow = shallow.limits.daily;
const frozenFlags = [Object.isFrozen(shallow), Object.isFrozen(shallow.limits)];

const deepFreeze = (value, seen = new WeakSet()) => {
  if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return value;
  if (seen.has(value)) return value;                       // a cycle, already handled
  seen.add(value);
  for (const key of Reflect.ownKeys(value)) {              // symbol keys included
    const desc = Object.getOwnPropertyDescriptor(value, key);
    if (desc && 'value' in desc) deepFreeze(desc.value, seen);   // never runs a getter
  }
  return Object.freeze(value);
};

const AUDIT = Symbol('audit');
const config = {
  till: 'Balogun 3',
  limits: { daily: 2000000, perOrder: { naira: 500000 } },
  stops: ['Yaba', 'Ikeja'],
  [AUDIT]: { by: 'Adaeze' },
};
deepFreeze(config);

const deepRefused = [
  attemptName(() => { config.limits.daily = 0; }),
  attemptName(() => { config.limits.perOrder.naira = 0; }),
  attemptName(() => { config.stops.push('Ojota'); }),
  attemptName(() => { config[AUDIT].by = 'someone else'; }),
];
const frozenAllTheWay = [
  Object.isFrozen(config.limits.perOrder),
  Object.isFrozen(config.stops),
  Object.isFrozen(config[AUDIT]),
];
const dataIntact = [config.limits.perOrder.naira, config.stops.slice()];

const cyclic = { stop: 'Oshodi' };
cyclic.self = cyclic;
deepFreeze(cyclic);
const cycleHandled = [Object.isFrozen(cyclic), Object.isFrozen(cyclic.self), cyclic.self.self.stop];

let getterCalls = 0;
const lazy = { fare: 400, get total() { getterCalls++; return this.fare + 50; } };
deepFreeze(lazy);
const notCalledDuringFreeze = getterCalls;
const stillComputes = [lazy.total, getterCalls];

const till = (() => {
  let naira = 0;
  return { get naira() { return naira; }, set take(amount) { naira += amount; } };
})();
deepFreeze(till);
const setterRan = attemptName(() => { till.take = 45000; });
const stateMoved = till.naira;

console.log('shallow freeze  :', topRefused, nestedAllowed, '-> nested is now', nestedNow);
console.log('deep freeze     :', deepRefused);
console.log('cycle           :', cycleHandled);
console.log('setter          :', setterRan, '-> naira is now', stateMoved);

Output


      
9 checks that prove this answer

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

9 checks

test('a plain freeze refuses the top level', [topRefused, frozenFlags[0]], ['TypeError', true]);
test('and lets a nested write straight through: freeze is one level deep', [nestedAllowed, nestedNow, frozenFlags[1]], ['no error', 0, false]);
test('deepFreeze refuses at every depth, arrays and symbol keys included', deepRefused, ['TypeError', 'TypeError', 'TypeError', 'TypeError']);
test('every nested object really is frozen, not just guarded', frozenAllTheWay, [true, true, true]);
test('and the data came through the walk unchanged', dataIntact, [500000, ['Yaba', 'Ikeja']]);
test('a cycle is handled by the seen set rather than recursing for ever', cycleHandled, [true, true, 'Oshodi']);
test('no getter ran during the freeze, because the walk reads descriptors', notCalledDuringFreeze, 0);
test('though the getter still computes afterwards', stillComputes, [450, 1]);
test('and freezing cannot stop a setter: it still moves state in its closure', [setterRan, stateMoved], ['no error', 45000]);

Object.freeze freezes one object. The second assertion is the whole problem: the top-level write threw and shallow.limits.daily = 0 succeeded, because limits is a different object and freezing the parent said nothing about it. Every "immutable config" bug looks like this.

Three details separate a working deepFreeze from the four-line version people write first. Reflect.ownKeys rather than Object.keys, or symbol-keyed values are missed entirely — and non-enumerable ones too. A WeakSet of visited objects, or a cycle recurses until the stack ends; WeakSet rather than Set so the bookkeeping does not pin the objects in memory. And reading getOwnPropertyDescriptor(obj, key).value rather than obj[key], so the walk does not invoke every accessor it passes — the seventh assertion. A getter can be expensive, can throw, can log, can lazily build the very object you are about to freeze; a function that "just freezes things" should not trigger any of that.

The last assertion is the limit of the whole idea. Freezing sets writable: false on data properties, but an accessor has no writable — it has a setter, and freezing only marks it non-configurable. Assigning to till.take still calls the setter, which still mutates the closed-over variable. A frozen object with accessors is not immutable, it is merely fixed in shape. Real immutability comes from not exposing the mutator.

Also worth knowing: freezing does not stop a proxy target from being replaced elsewhere, does not affect a Map's or Set's contents — the entries live in an internal slot, not in properties, as exercise 63 explains — and cannot be undone. There is no unfreeze.

75 A computed property that cannot be assigned

hard

Give an order a total that is computed from its items, refuses assignment, and stays out of Object.keys and JSON.stringify. Then make the same thing enumerable and compare. Contrast it with a non-writable data property, which also refuses assignment but never recomputes, and find the descriptor the engine rejects outright.

Stuck? Three hints, smallest first
  1. 1. A getter with no setter is the read-only part. The attributes control the visibility.
  2. 2. Assigning to an accessor with no setter throws in strict mode.
  3. 3. A descriptor cannot describe both an accessor and a value. Try it and read the error.

The answer

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

const order = { items: [{ naira: 25000 }, { naira: 20000 }] };
Object.defineProperty(order, 'total', {
  get() { return this.items.reduce((sum, item) => sum + item.naira, 0); },
  enumerable: false,
  configurable: true,
});

const computed = order.total;
order.items.push({ naira: 5000 });
const recomputed = order.total;

const assignRefused = attemptName(() => { order.total = 1; });
const hidden = [Object.keys(order), JSON.stringify(order), Object.hasOwn(order, 'total')];
const spreadDropsIt = Object.keys({ ...order });

Object.defineProperty(order, 'total', {
  get() { return this.items.reduce((sum, item) => sum + item.naira, 0); },
  enumerable: true,
  configurable: true,
});
const nowVisible = [Object.keys(order), JSON.parse(JSON.stringify(order)).total];

// A non-writable data property: also refuses assignment, and never recomputes.
const snapshot = { items: order.items };
Object.defineProperty(snapshot, 'total', { value: 50000, writable: false, enumerable: true });
const dataRefused = attemptName(() => { snapshot.total = 1; });
snapshot.items.push({ naira: 100000 });
const staleTotal = snapshot.total;
const liveTotal = order.total;

const bothAtOnce = attemptName(() => Object.defineProperty({}, 'total', { value: 1, get() { return 2; } }));
const kinds = [
  typeof Object.getOwnPropertyDescriptor(order, 'total').get,
  'writable' in Object.getOwnPropertyDescriptor(order, 'total'),
  typeof Object.getOwnPropertyDescriptor(snapshot, 'total').get,
];

console.log('computed, recomputed:', computed, recomputed);
console.log('hidden              :', hidden);
console.log('stale vs live       :', staleTotal, liveTotal);

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 getter computes from the items', computed, 45000);
test('and follows them, which is the point of a computed property', recomputed, 50000);
test('assigning to it throws: an accessor with no setter is read-only', assignRefused, 'TypeError');
test('non-enumerable keeps it out of keys, JSON and spread, while hasOwn still finds it', [hidden, spreadDropsIt], [[['items'], '{"items":[{"naira":25000},{"naira":20000},{"naira":5000}]}', true], ['items']]);
test('redefining it as enumerable puts it into both', nowVisible, [['items', 'total'], 50000]);
test('a non-writable data property also refuses assignment', dataRefused, 'TypeError');
test('but it is a stored number, so it goes stale while the accessor stays live', [staleTotal, liveTotal], [50000, 150000]);
test('a descriptor may not describe both an accessor and a value', bothAtOnce, 'TypeError');
test('and the descriptor tells the two kinds apart', kinds, ['function', false, 'undefined']);

Read-only has two implementations and they behave differently over time. A getter with no setter recomputes on every read, so it cannot go stale; a data property with writable: false is a number that was correct once. The seventh assertion is the two side by side after the items changed: 50,000 stored against 150,000 computed.

Which to choose is a question about cost, not about safety. The accessor is right for a total, a full name, a formatted amount — anything cheap and derived. The frozen value is right when the number is a record of something, like the total at the moment an invoice was issued, where recomputing later would be wrong. Reaching for a getter to cache an expensive computation is the case where neither fits, and you want a getter that memoises into a hidden field.

Enumerability is the other half of the decision, and it is the mistake worth avoiding. An enumerable computed property gets serialised, which means it comes back from the server as a plain number that no longer recomputes and now disagrees with the items. Keep derived values non-enumerable and let the consumer ask for them, or serialise deliberately through a toJSON that says what it is doing.

The eighth assertion is a small guard rail worth knowing about: a descriptor is either a data descriptor or an accessor descriptor, and mixing value or writable with get or set is rejected before anything happens. It usually means a half-finished edit, which is exactly when you want a loud error.

76 The prototype chain, and what shadowing really does

fair

Build a two-link chain with Object.create. Read an inherited property, then assign to the same name and work out where the value landed. Delete it again. Compare Object.keys with for...in, walk the chain to its end, and see what an object with no prototype cannot do.

Stuck? Three hints, smallest first
  1. 1. A read walks up the chain. A write always lands on the object you wrote to.
  2. 2. Object.hasOwn and in disagree about exactly the inherited properties.
  3. 3. Object.create(null) has no toString, so converting it to a string throws.

The answer

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

const vehicle = {
  wheels: 4,
  describe() { return this.name + ' with ' + this.wheels + ' wheels'; },
};
const danfo = Object.create(vehicle);
danfo.name = 'danfo';

const inherited = [danfo.wheels, Object.hasOwn(danfo, 'wheels'), 'wheels' in danfo];
const methodRuns = danfo.describe();

danfo.wheels = 6;                              // shadows; the prototype is untouched
const afterShadowing = [danfo.wheels, vehicle.wheels, Object.hasOwn(danfo, 'wheels')];
const shadowedDescribe = danfo.describe();

delete danfo.wheels;                           // the shadow goes, the inherited value returns
const afterDelete = [danfo.wheels, Object.hasOwn(danfo, 'wheels')];

const ownOnly = Object.keys(danfo);
const walked = [];
for (const key in danfo) walked.push(key);

const links = [Object.getPrototypeOf(danfo) === vehicle, vehicle.isPrototypeOf(danfo)];
const chain = [];
for (let link = Object.getPrototypeOf(danfo); link !== null; link = Object.getPrototypeOf(link)) {
  chain.push(link === vehicle ? 'vehicle' : link === Object.prototype ? 'Object.prototype' : 'other');
}

const bare = Object.create(null);
bare.name = 'keko';
const bareChecks = [Object.getPrototypeOf(bare), 'toString' in bare, Object.keys(bare)];
const bareThrows = attemptName(() => String(bare));

console.log('inherited      :', inherited);
console.log('after shadowing:', afterShadowing);
console.log('after delete   :', afterDelete);
console.log('chain          :', chain);

Output


      
9 checks that prove this answer

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

9 checks

test('an inherited property reads as if it were the object\'s own, but is not', inherited, [4, false, true]);
test('an inherited method runs with this as the object it was called on', methodRuns, 'danfo with 4 wheels');
test('assigning creates an own property and leaves the prototype alone', afterShadowing, [6, 4, true]);
test('so the inherited method now reads the shadowing value', shadowedDescribe, 'danfo with 6 wheels');
test('deleting the shadow uncovers the inherited value again', afterDelete, [4, false]);
test('Object.keys is own keys only, while for...in walks the chain', [ownOnly, walked], [['name'], ['name', 'wheels', 'describe']]);
test('the link is readable from both ends, and the chain ends at Object.prototype', [links, chain], [[true, true], ['vehicle', 'Object.prototype']]);
test('Object.create(null) has no chain, so no toString to inherit', bareChecks, [null, false, ['name']]);
test('which makes even String() on it a TypeError', bareThrows, 'TypeError');

The asymmetry is the whole subject: reads walk up the chain, writes never do. danfo.wheels = 6 does not modify vehicle and does not "override" anything — it creates an own property that the lookup finds first. That is why delete appears to restore a value it never touched, and why one shared prototype can safely back a thousand objects.

The trap sits one level down from here. Shadowing works cleanly for primitives, and hides a real problem for objects: if the prototype holds { tags: [] }, then obj.tags.push('x') is a read followed by a mutation, so every object sharing that prototype sees the push. Only an assignment shadows. That is the shared-state bug behind "why did adding a tag to one order add it to all of them".

for...in walking the chain is a leftover that still causes trouble: it lists inherited enumerable keys, which here means the method. That is why for...in over data needs Object.hasOwn guarding it, why class methods are non-enumerable by default — exercise 78 — and why Object.keys or Object.entries is what you actually want.

Object.create(null) is the deliberate opt-out, and the eighth and ninth assertions show both sides of it. No toString, no hasOwnProperty, no __proto__ accessor — which is precisely what you want from a lookup table keyed by user input, since a key called "toString" or "__proto__" is then just a key. The price is that the object cannot be logged, concatenated or compared as a string without throwing.

77 A prototype method is shared, a class field is not

hard

Give a class one method and one arrow-function field that do the same thing. Compare them across two instances by identity, by Object.keys and through JSON.stringify. Then detach both and call them, and try to move each one with call.

Stuck? Three hints, smallest first
  1. 1. A method is defined once on the prototype. A field is assigned per instance, in the constructor.
  2. 2. An arrow captures this where it was created, so call has nothing to change.
  3. 3. A detached prototype method has this === undefined in a class body, which is always strict.

The answer

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

class Bus {
  constructor(route) { this.route = route; }
  label() { return 'bus to ' + this.route; }              // on the prototype
  labelArrow = () => 'bus to ' + this.route;              // own property, per instance
}

const ojota = new Bus('Ojota');
const yaba = new Bus('Yaba');

const bothWork = [ojota.label(), ojota.labelArrow()];
const methodShared = ojota.label === yaba.label;
const fieldPerInstance = ojota.labelArrow === yaba.labelArrow;
const whereTheyLive = [
  Object.hasOwn(ojota, 'label'),
  Object.hasOwn(ojota, 'labelArrow'),
  Object.hasOwn(Bus.prototype, 'label'),
];
const listed = [Object.keys(ojota), JSON.stringify(ojota)];

const moved = ojota.label.call(yaba);
const arrowRefusesToMove = ojota.labelArrow.call(yaba);
const boundCannotHelp = ojota.labelArrow.bind(yaba)();

const detachedMethod = ojota.label;
const detachedArrow = ojota.labelArrow;
const methodLostThis = attemptName(() => detachedMethod());
const arrowStillWorks = detachedArrow();
const rebound = detachedMethod.bind(ojota)();

console.log('shared, per instance:', methodShared, fieldPerInstance);
console.log('own keys            :', listed);
console.log('call on each        :', moved, '|', arrowRefusesToMove);
console.log('detached            :', methodLostThis, '|', arrowStillWorks);

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('both produce the same text, which is what hides the difference', bothWork, ['bus to Ojota', 'bus to Ojota']);
test('the method is one function shared by every instance', methodShared, true);
test('the field is a new function on each instance', fieldPerInstance, false);
test('so the field is an own property and the method is not', whereTheyLive, [false, true, true]);
test('which puts the field in Object.keys, ahead of route, though JSON drops functions', listed, [['labelArrow', 'route'], '{"route":"Ojota"}']);
test('call moves a prototype method to another instance', moved, 'bus to Yaba');
test('but the arrow captured this at construction, so call and bind cannot move it', [arrowRefusesToMove, boundCannotHelp], ['bus to Ojota', 'bus to Ojota']);
test('a detached method loses this and throws in strict mode', methodLostThis, 'TypeError');
test('while the detached arrow still works, and bind fixes the method', [arrowStillWorks, rebound], ['bus to Ojota', 'bus to Ojota']);

The two definitions read almost identically and are structurally opposite. label is created once, when the class is evaluated, and lives on Bus.prototype; every instance borrows it and supplies this at the call. labelArrow is an initialiser: it runs per construction, so a thousand buses allocate a thousand closures, each holding its own this.

That is the trade. The field is immune to detachment — the eighth and ninth assertions — which is why onClick={this.handleClick} works when handleClick is a field and throws when it is a method. The cost is memory, and something subtler: the seventh assertion shows call and bind being ignored. An arrow's this is lexical and permanent, so a field cannot be borrowed, cannot be tested against a stub receiver, and cannot be overridden by a subclass method of the same name in the usual way — the instance's own property always wins over the prototype's.

The fifth assertion is the one that surprises people in code review: class fields are enumerable own properties, so they show up in Object.keys, in spread and in for...in, right next to your data — and before it, because field initialisers run before the constructor body, which is also why a field cannot read a value the constructor has not assigned yet. JSON.stringify happens to drop them because functions do not serialise, but { ...bus } copies the closure and any snapshot-diffing code now sees a function among the fields.

The TypeError in the eighth assertion is not "undefined is not a function" — it is reading route off undefined. Class bodies are always strict, so a detached method's this is undefined rather than the global object, which is a mercy: the sloppy-mode version silently reads globalThis.route and returns 'bus to undefined'.

78 What class syntax is sugar for, and what it is not

hard

Write the same type twice: once as a class, once as a function with methods on its prototype. Show that the instances are indistinguishable, then find the four things that genuinely differ — method enumerability, calling without new, the static side, and what new.target reports.

Stuck? Three hints, smallest first
  1. 1. Assigning to Fn.prototype.method creates an enumerable property. A class method is non-enumerable.
  2. 2. A class constructor refuses to be called without new. A function does not.
  3. 3. new.target is undefined in a plain call and the constructor in a new one.

The answer

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

class BusClass {
  static depot = 'Ojota';
  constructor(route) { this.route = route; }
  label() { return 'bus to ' + this.route; }
  get shortRoute() { return this.route.slice(0, 3); }
  static fromText(text) { return new BusClass(text.trim()); }
}

function BusFunction(route) { this.route = route; }
BusFunction.depot = 'Ojota';
BusFunction.prototype.label = function () { return 'bus to ' + this.route; };
BusFunction.fromText = function (text) { return new BusFunction(text.trim()); };

const a = new BusClass('Ojuelegba');
const b = new BusFunction('Ojuelegba');

const sameOutwardly = [
  a.label() === b.label(),
  a instanceof BusClass && b instanceof BusFunction,
  Object.getPrototypeOf(a) === BusClass.prototype && Object.getPrototypeOf(b) === BusFunction.prototype,
  typeof BusClass === typeof BusFunction,
];
const constructorLinks = [BusClass.prototype.constructor === BusClass, BusFunction.prototype.constructor === BusFunction];

const enumerability = [Object.keys(BusClass.prototype), Object.keys(BusFunction.prototype)];
const inForIn = [];
for (const key in b) inForIn.push(key);
const classInForIn = [];
for (const key in a) classInForIn.push(key);

class EmptyClass { constructor() {} }
function emptyFunction() {}
const withoutNew = [
  attemptName(() => BusClass('Ojota')),        // refused for being a class
  attemptName(() => EmptyClass()),             // refused even with a body that does nothing
  attemptName(() => emptyFunction()),          // just runs
  attemptName(() => BusFunction('Ojota')),     // fails for a different reason entirely
];
const strayGlobal = typeof globalThis.route;

let seen = 'never ran';
function Factory() { seen = new.target === undefined ? 'called' : 'constructed'; }
new Factory();
const withNew = seen;
Factory();
const withoutNewSeen = seen;

const staticSide = [
  Object.hasOwn(BusClass, 'depot'),
  Object.hasOwn(BusClass.prototype, 'depot'),
  BusClass.fromText('  Ojota  ').route,
];
const accessorOnPrototype = [
  typeof Object.getOwnPropertyDescriptor(BusClass.prototype, 'shortRoute').get,
  a.shortRoute,
];

console.log('indistinguishable:', sameOutwardly);
console.log('enumerable       :', enumerability);
console.log('without new      :', withoutNew);
console.log('new.target       :', withNew, '/', withoutNewSeen);

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 instances are indistinguishable: same output, same chain, same typeof', sameOutwardly, [true, true, true, true]);
test('and both prototypes point back at their constructor', constructorLinks, [true, true]);
test('but class methods are non-enumerable, and hand-assigned ones are not', enumerability, [[], ['label']]);
test('so for...in over the function version lists the method as if it were data', [inForIn, classInForIn], [['route', 'label'], ['route']]);
test('a class refuses to be called without new whatever its body does, a function just runs', withoutNew.slice(0, 3), ['TypeError', 'TypeError', 'no error']);
test('the function version fails only because strict mode left it no this, so nothing reached the global', [withoutNew[3], strayGlobal], ['TypeError', 'undefined']);
test('new.target is how a plain function can tell the two apart', [withNew, withoutNewSeen], ['constructed', 'called']);
test('statics live on the constructor, never on the prototype', staticSide, [true, false, 'Ojota']);
test('and a class getter is a real accessor on the prototype', accessorOnPrototype, ['function', 'Oju']);

"Classes are just sugar" is close enough to be useful and wrong in four places, all of them visible above. Method enumerability is the one you feel first: hand-assigned prototype methods are enumerable, so for...in over an instance lists behaviour among the data — the fourth assertion — which is why old code is full of hasOwnProperty guards. Class methods are non-enumerable, and that alone removes a whole category of bug.

The new check is the second, and the two failures are not the same failure. A class is refused because it is a class — EmptyClass() throws with a body that does nothing at all. BusFunction('Ojota') is not refused; it runs, and only then throws because strict mode gave it no this to write route onto. In sloppy code that same call would have succeeded, writing a global route and returning undefined, so the caller gets nothing and the damage is elsewhere. new.target is the pre-class defence, and still the way a factory function can insist on one calling convention.

Two more differences that do not appear here. Class declarations are not hoisted the way function declarations are: the binding exists but is uninitialised, so touching it before the declaration is a ReferenceError rather than undefined. And a class body is always strict, whatever the surrounding code.

What is genuinely the same is worth as much as what differs. There is one object model underneath: a constructor function, a prototype object holding the shared methods, a constructor link back, instances whose [[Prototype]] is that object, and statics as plain properties of the function. extends is Object.setPrototypeOf applied to both the prototype and the constructor, which is why static members are inherited too.

79 super is resolved from the method, not from this

hard

Build a three-level chain where each describe calls super.describe(). Then copy the middle one onto an unrelated object and call it there: work out which parent it reaches and which object supplies this. Write the naive this.constructor.prototype version and watch it recurse. Finish with super in an object literal and in a static method.

Stuck? Three hints, smallest first
  1. 1. A method written with super remembers the object it was defined in. That is what super looks up from.
  2. 2. this is still the receiver, so the parent method reads the caller's data.
  3. 3. this.constructor.prototype is the instance's class, not the parent's. Follow it once and you are back where you started.

The answer

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

class Base {
  describe() { return 'base:' + this.till; }
  static origin() { return 'Base'; }
}
class Middle extends Base {
  describe() { return 'middle -> ' + super.describe(); }
  static origin() { return 'Middle of ' + super.origin(); }
}
class Leaf extends Middle {
  describe() { return 'leaf -> ' + super.describe(); }
}

const leaf = new Leaf();
leaf.till = 'Balogun 3';
const wholeChain = leaf.describe();

// The same method, on an object with nothing to do with these classes.
const alien = { till: 'Ikeja 1', describe: Middle.prototype.describe };
const stillReachesBase = alien.describe();
const alienChain = Object.getPrototypeOf(alien) === Object.prototype;

let depth = 0;
class Naive extends Base {
  describe() {
    depth++;
    if (depth > 3) return 'still going round after ' + depth + ' calls';
    return 'naive -> ' + this.constructor.prototype.describe.call(this);
  }
}
const naive = new Naive();
naive.till = 'Yaba';
const recursed = naive.describe();

const parent = { greet() { return 'from the parent'; } };
const child = { __proto__: parent, greet() { return 'child then ' + super.greet(); } };
const literalSuper = child.greet();
const copied = { ...child };
const copiedStillWorks = [Object.getPrototypeOf(copied) === Object.prototype, copied.greet()];

const staticSuper = Middle.origin();

class TooEarly extends Base {
  constructor() { try { this.till = 'Yaba'; } catch (err) { throw err; } }
}
const beforeSuper = attemptName(() => new TooEarly());

console.log('whole chain     :', wholeChain);
console.log('on an alien     :', stillReachesBase);
console.log('naive version   :', recursed, 'after', depth, 'calls');
console.log('literal super   :', literalSuper, '| copied:', copiedStillWorks[1]);

Output


      
9 checks that prove this answer

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

9 checks

test('each super.describe climbs one link, and this stays the instance', wholeChain, 'leaf -> middle -> base:Balogun 3');
test('the same method on an unrelated object still reaches Base', stillReachesBase, 'middle -> base:Ikeja 1');
test('even though that object\'s prototype is only Object.prototype', alienChain, true);
test('so super comes from where the method was defined, not from this', [leaf.describe.call({ till: 'Ojota' }), depth > 0], ['leaf -> middle -> base:Ojota', true]);
test('the naive this.constructor.prototype version calls itself instead of climbing', recursed, 'naive -> naive -> naive -> still going round after 4 calls');
test('super works in an object literal, against the literal\'s prototype', literalSuper, 'child then from the parent');
test('and survives being copied onto an object with a different prototype', copiedStillWorks, [true, 'child then from the parent']);
test('a static method\'s super is the parent class, not the parent prototype', staticSuper, 'Middle of Base');
test('and touching this before super() in a derived constructor is a ReferenceError', beforeSuper, 'ReferenceError');

Every method written with super carries a hidden reference to the object it was defined in — its home object. super.describe() means "start the lookup at the prototype of my home object", so it depends on where the method was written and not on the receiver at all. The second and third assertions are that stated as strongly as it can be: Middle.prototype.describe, called as a method of a plain object literal, still reaches Base.prototype.describe.

this is the other half, and it does not move: the parent method read Ikeja 1 off the alien object. So super fixes the method to call and leaves the data to the receiver, which is exactly what an override needs.

The fifth assertion is the bug this design prevents. this.constructor.prototype is the prototype of the instance's own class — for a Naive instance that is Naive.prototype, so the "call the parent" line calls itself, and without the depth guard the stack ends. It is the standard failure of hand-rolled inheritance, and it only appears once someone subclasses a class that already used the trick.

Two practical notes. In an object literal, super is available only in shorthand methods — greet() {}, not greet: function () {} — and it follows the literal's prototype, which is why the __proto__: line matters. And in a derived constructor, this does not exist until super() has run: the binding is in a temporal dead zone and touching it is a ReferenceError, because the parent is what creates the object.

80 Mixins, call, apply, bind, and the this that goes missing

hard

Compose behaviour without multiple inheritance: write mixins as functions from a base class to a subclass, stack two of them, and walk the resulting chain. Then borrow one of the methods for a plain object with call and apply, fix a receiver with bind, try to move a bound function, and detach a method to see it fail.

Stuck? Three hints, smallest first
  1. 1. A mixin is (Base) => class extends Base { ... }. Each application adds one link to the chain.
  2. 2. call and apply differ only in how the arguments arrive.
  3. 3. A bound function's this is final. A later call supplies arguments and nothing else.

The answer

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

class Party {
  constructor(name) { this.name = name; }
  describe() { return 'party ' + this.name; }
}

const Auditable = (Base) => class extends Base {
  audit(action) { return this.name + ' ' + action + ', logged'; }
};
const Payable = (Base) => class extends Base {
  pay(naira) { return this.name + ' paid ' + naira; }
};

const AuditableParty = Auditable(Party);
const PayableAuditableParty = Payable(AuditableParty);
class Vendor extends PayableAuditableParty {}

const vendor = new Vendor('Adaeze');
const bothBehaviours = [vendor.pay(45000), vendor.audit('restocked'), vendor.describe()];

const chain = [];
for (let link = Object.getPrototypeOf(vendor); link !== null; link = Object.getPrototypeOf(link)) {
  chain.push(
    link === Vendor.prototype ? 'Vendor'
      : link === PayableAuditableParty.prototype ? 'Payable'
      : link === AuditableParty.prototype ? 'Auditable'
      : link === Party.prototype ? 'Party'
      : 'Object.prototype'
  );
}
const instanceOfEach = [vendor instanceof Vendor, vendor instanceof AuditableParty, vendor instanceof Party];
const whereTheMethodsAre = [
  Object.getOwnPropertyNames(PayableAuditableParty.prototype),
  Object.getOwnPropertyNames(AuditableParty.prototype),
];

const borrower = { name: 'Chidi' };
const called = Vendor.prototype.pay.call(borrower, 5000);
const applied = Vendor.prototype.pay.apply(borrower, [5000]);

const bound = Vendor.prototype.pay.bind({ name: 'Ngozi' });
const boundCall = bound(7000);
const cannotRebind = bound.call({ name: 'somebody else' }, 7000);
const partiallyApplied = Vendor.prototype.pay.bind({ name: 'Ngozi' }, 7000)();
const boundLength = [bound.length, bound.name];

const detached = vendor.pay;
const lostThis = attemptName(() => detached(45000));
const fixedAgain = detached.bind(vendor)(45000);

console.log('both behaviours:', bothBehaviours);
console.log('chain          :', chain);
console.log('borrowed       :', called, '|', applied);
console.log('bound          :', boundCall, '| re-bound:', cannotRebind);

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 stacked mixins give the class every behaviour', bothBehaviours, ['Adaeze paid 45000', 'Adaeze restocked, logged', 'party Adaeze']);
test('each mixin is one real link in the chain, in application order', chain, ['Vendor', 'Payable', 'Auditable', 'Party', 'Object.prototype']);
test('so instanceof answers for every class you kept a reference to', instanceOfEach, [true, true, true]);
test('and each mixin owns only its own method', whereTheMethodsAre, [['constructor', 'pay'], ['constructor', 'audit']]);
test('call and apply borrow the method for any object of the right shape', [called, applied], ['Chidi paid 5000', 'Chidi paid 5000']);
test('bind fixes this permanently: a later call cannot move it', [boundCall, cannotRebind], ['Ngozi paid 7000', 'Ngozi paid 7000']);
test('bind also fixes leading arguments, which is partial application', partiallyApplied, 'Ngozi paid 7000');
test('and the bound function reports the arity left and a bound name', boundLength, [1, 'bound pay']);
test('a detached method loses this and throws, and bind is what puts it back', [lostThis, fixedAgain], ['TypeError', 'Adaeze paid 45000']);

A mixin is a function from a class to a class, and that is the whole trick: Payable(Auditable(Party)) builds a genuine chain rather than merging bags of methods. The second assertion is why this beats Object.assign(Class.prototype, methods) — each behaviour is a distinct link, so super works inside a mixin, an override resolves in the usual order, and instanceof answers for any layer you kept a reference to.

The cost is the reference. Payable(Auditable(Party)) produces anonymous classes, so unless you name them on the way up, as here, there is nothing to test with instanceof and stack traces are unhelpful. Applying the same mixin to two bases also produces two unrelated classes, which is the usual reason a mixin-heavy codebase ends up caching them.

call, apply and bind are the manual controls for the same this the chain supplies automatically. call and apply differ only in argument shape — spread has made apply mostly redundant — and both let a method be borrowed by any object with the right fields, which is how Array.prototype.slice.call(arguments) worked for a decade. bind is different in kind: it returns a new function whose receiver is final, which the sixth assertion proves by trying to override it and failing. Note the arity in the eighth assertion: binding an argument reduces length, and the name gains a bound prefix, both of which matter if anything inspects your functions.

The last assertion is the one to carry around. const detached = vendor.pay copies the function and nothing else — the receiver was never part of it, it comes from the call site. Passing a method as a callback is exactly that copy, which is why setTimeout(vendor.pay, 0) throws while setTimeout(() => vendor.pay(45000), 0) does not, and why bind or an arrow wrapper is the fix.

81 A paginator that reports the page it actually gave you

easy

Given a list of orders and a requested page, return the rows for that page plus everything a footer needs: how many pages there are, which page you are really on, whether there is a next or a previous page, and a "showing 1-10 of 23" label. Requests outside the range must be clamped rather than rejected, and an empty list must still describe itself sensibly.

Stuck? Three hints, smallest first
  1. 1. The page count is Math.ceil(total / perPage) — but check what that gives for an empty list.
  2. 2. Clamp the requested page into 1 .. pages before you slice, and return the clamped number so the caller can render it.
  3. 3. slice past the end of an array returns []. It never throws, so you do not need to guard the upper bound.

The answer

const orders = Array.from({ length: 23 }, (_, i) => 'ORD-' + String(i + 1).padStart(3, '0'));

function paginate(items, { page = 1, perPage = 10 } = {}) {
  const total = items.length;
  const pages = Math.max(1, Math.ceil(total / perPage));   // an empty list is still page 1 of 1
  const current = Math.min(Math.max(1, Math.trunc(page)), pages);
  const from = (current - 1) * perPage;
  const rows = items.slice(from, from + perPage);          // slice clamps for us
  return {
    page: current,
    pages,
    total,
    rows,
    hasNext: current < pages,
    hasPrev: current > 1,
    label: 'Showing ' + (total === 0 ? 0 : from + 1) + '-' + Math.min(from + perPage, total) + ' of ' + total,
  };
}

const first = paginate(orders);
const last = paginate(orders, { page: 3 });
const wayPastTheEnd = paginate(orders, { page: 9 });
const beforeTheStart = paginate(orders, { page: 0 });
const nothingAtAll = paginate([], { perPage: 10 });
const exactFit = paginate(Array.from({ length: 30 }, (_, i) => i + 1), { page: 3, perPage: 10 });

console.log('page 1 :', first.label, '| next?', first.hasNext);
console.log('page 3 :', last.label, '| next?', last.hasNext);
console.log('page 9 :', 'asked for 9, got', wayPastTheEnd.page);
console.log('empty  :', nothingAtAll.label, '| pages', nothingAtAll.pages);

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('23 rows at 10 a page is 3 pages, and page 1 has no previous', [first.page, first.pages, first.hasPrev, first.hasNext], [1, 3, false, true]);
test('the first page is the first ten rows, with a human label', [first.rows.length, first.rows[0], first.label], [10, 'ORD-001', 'Showing 1-10 of 23']);
test('the last page holds the remainder and offers no next', [last.rows, last.hasNext, last.label], [['ORD-021', 'ORD-022', 'ORD-023'], false, 'Showing 21-23 of 23']);
test('a page past the end is clamped, and it says which page it gave you', [wayPastTheEnd.page, wayPastTheEnd.rows.length], [3, 3]);
test('page 0 clamps up to 1 rather than slicing from -10', [beforeTheStart.page, beforeTheStart.rows[0]], [1, 'ORD-001']);
test('an empty list is one empty page, not zero pages', [nothingAtAll.pages, nothingAtAll.rows, nothingAtAll.label], [1, [], 'Showing 0-0 of 0']);
test('when perPage divides exactly there is no phantom extra page', [exactFit.pages, exactFit.hasNext, exactFit.rows.length], [3, false, 10]);

Two off-by-one bugs live in every hand-written paginator, and both are asserted here. Math.ceil(0 / 10) is 0, so an empty list claims to have zero pages and the footer renders "page 1 of 0"; the Math.max(1, ...) is what stops that. And 30 rows at 10 a page is 3 pages, not 4 — Math.ceil gets that right, while the Math.floor(total / perPage) + 1 version people reach for first gives 4 and a final empty page.

Returning the clamped page number matters more than it looks. A user who bookmarks ?page=9, or a delete that shrinks the list under them, arrives asking for a page that no longer exists. Clamping and reporting means the UI highlights page 3 and the URL can be corrected; rejecting means an error screen for something that is not an error.

slice doing the clamping for you is the small mercy: orders.slice(200, 210) is [], not a throw and not undefined, so the upper bound needs no guard at all.

82 Search and filter with every criterion optional

fair

Filter a list of vendors by any combination of a free-text query, one or more cities, a minimum rating and an active flag. Criteria that were not supplied must not narrow anything. Then write the version most people write first — the one that tests each criterion for truthiness — and show what it does when you ask for the inactive vendors.

Stuck? Three hints, smallest first
  1. 1. Build an array of predicate functions from the criteria that are present, then filter with every.
  2. 2. The test for "was this criterion supplied" is !== undefined, not truthiness.
  3. 3. [].concat(value) accepts one city or an array of them without a branch.

The answer

const vendors = [
  { id: 1, name: 'Adaeze Fabrics', city: 'Lagos', rating: 4.6, active: true },
  { id: 2, name: 'Balogun Textiles', city: 'Lagos', rating: 3.9, active: true },
  { id: 3, name: 'Chidi Electronics', city: 'Abuja', rating: 4.8, active: false },
  { id: 4, name: 'Ngozi Fabrics', city: 'Ibadan', rating: 4.2, active: true },
  { id: 5, name: 'Yaba Tech Spares', city: 'Lagos', rating: 2.7, active: false },
];

function search(rows, criteria = {}) {
  const tests = [];
  if (criteria.q !== undefined && criteria.q.trim() !== '') {
    const needle = criteria.q.trim().toLowerCase();
    tests.push((r) => r.name.toLowerCase().includes(needle));
  }
  if (criteria.city !== undefined) {
    const cities = [].concat(criteria.city);
    tests.push((r) => cities.includes(r.city));
  }
  if (criteria.minRating !== undefined) tests.push((r) => r.rating >= criteria.minRating);
  if (criteria.active !== undefined) tests.push((r) => r.active === criteria.active);
  return rows.filter((r) => tests.every((t) => t(r)));
}

// The version that checks truthiness instead of presence.
function naiveSearch(rows, criteria = {}) {
  return rows.filter((r) =>
    (!criteria.q || r.name.toLowerCase().includes(criteria.q.toLowerCase())) &&
    (!criteria.city || r.city === criteria.city) &&
    (!criteria.minRating || r.rating >= criteria.minRating) &&
    (!criteria.active || r.active === criteria.active));
}

const names = (rows) => rows.map((r) => r.name);

const everything = search(vendors);
const byText = search(vendors, { q: '  FABRICS ' });
const lagosOnly = search(vendors, { city: 'Lagos' });
const twoCriteria = search(vendors, { city: 'Lagos', minRating: 4 });
const severalCities = search(vendors, { city: ['Abuja', 'Ibadan'] });
const onlyInactive = search(vendors, { active: false });
const naiveInactive = naiveSearch(vendors, { active: false });
const floorOfZero = search(vendors, { minRating: 0 });
const blankQuery = search(vendors, { q: '   ' });
const noMatch = search(vendors, { city: 'Kano', q: 'fabrics' });

console.log('no criteria :', everything.length, 'rows');
console.log('q=fabrics   :', names(byText));
console.log('inactive    :', names(onlyInactive));
console.log('naive same  :', names(naiveInactive), '<- the bug');

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('no criteria means no filtering', everything.length, 5);
test('the query is trimmed and case-insensitive', names(byText), ['Adaeze Fabrics', 'Ngozi Fabrics']);
test('a second criterion narrows: three Lagos vendors, one of them rated 4 or over', [lagosOnly.length, names(twoCriteria)], [3, ['Adaeze Fabrics']]);
test('city accepts one value or a list of them', names(severalCities), ['Chidi Electronics', 'Ngozi Fabrics']);
test('active: false really means inactive', names(onlyInactive), ['Chidi Electronics', 'Yaba Tech Spares']);
test('the truthiness version ignores it and returns everybody', naiveInactive.length, 5);
test('minRating: 0 is a real floor that happens to admit everyone', floorOfZero.length, 5);
test('a whitespace-only query is treated as no query', blankQuery.length, 5);
test('and criteria that match nothing give an empty list, not everything', noMatch, []);

The shape here — collect predicates, then rows.filter(r => tests.every(t => t(r))) — is worth keeping. Each criterion contributes one small function, the empty criteria object yields an empty tests array, and [].every(...) is true, so "no filters" falls out of the arithmetic instead of needing a special case.

The sixth assertion is the bug this exists to kill. !criteria.active is true when active is false, so the whole clause short-circuits away and the filter for inactive vendors returns every vendor. The same hole swallows minRating: 0, q: '' and page: 0. Any criterion whose valid values include a falsy one needs !== undefined, and active, 0 and '' are the three that turn up in real forms.

The one place truthiness is right is the blank query: a user who typed spaces into a search box supplied nothing, so q.trim() !== '' is a deliberate second condition rather than an oversight.

83 A sort with one row pinned and ties broken by a second key

fair

Rank riders by deliveries, highest first, breaking ties by name A to Z — and keep one chosen rider pinned to the top whatever his numbers say. Leave the caller's array alone. Then look at why the comparator returns numbers rather than booleans.

Stuck? Three hints, smallest first
  1. 1. A comparator can be several comparisons joined with ||, because a tie returns 0 and 0 is falsy.
  2. 2. Turn "is this the pinned row" into a number — 0 for pinned, 1 for not — and subtract.
  3. 3. sort sorts in place and returns the same array, so copy with [...rows] first.

The answer

const riders = [
  { id: 'R1', name: 'Chinedu', deliveries: 41 },
  { id: 'R2', name: 'Bukola', deliveries: 12 },
  { id: 'R3', name: 'Amaka', deliveries: 63 },
  { id: 'R4', name: 'Zainab', deliveries: 41 },
  { id: 'R5', name: 'Tunde', deliveries: 63 },
];

const compareRiders = (pinnedId) => (a, b) =>
  (a.id === pinnedId ? 0 : 1) - (b.id === pinnedId ? 0 : 1) ||   // pinned row first
  b.deliveries - a.deliveries ||                                  // then deliveries, descending
  a.name.localeCompare(b.name);                                   // then name, A to Z

const rank = (rows, pinnedId) => [...rows].sort(compareRiders(pinnedId));

const names = (rows) => rows.map((r) => r.name);

const withPin = rank(riders, 'R2');
const withoutPin = rank(riders);
const pinNotPresent = rank(riders, 'R99');
const originalUntouched = names(riders);

const pinnedBeatsEverything = compareRiders('R2')(riders[1], riders[2]) < 0;
const identicalRows = compareRiders(null)({ id: 'x', name: 'Amaka', deliveries: 63 }, riders[2]);
const booleanComparator = [riders[1].deliveries > riders[2].deliveries, Number(riders[1].deliveries > riders[2].deliveries)];

console.log('pinned R2 :', names(withPin));
console.log('unpinned  :', names(withoutPin));
console.log('original  :', originalUntouched, '<- still as written');

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 pinned rider is first even with the fewest deliveries', names(withPin), ['Bukola', 'Amaka', 'Tunde', 'Chinedu', 'Zainab']);
test('and the comparator says so directly', pinnedBeatsEverything, true);
test('without a pin it is deliveries descending', names(withoutPin).slice(0, 2), ['Amaka', 'Tunde']);
test('ties are broken by name, so Amaka precedes Tunde and Chinedu precedes Zainab', names(withoutPin), ['Amaka', 'Tunde', 'Chinedu', 'Zainab', 'Bukola']);
test('pinning an id that is not in the list changes nothing', names(pinNotPresent), names(withoutPin));
test('the caller\'s array was not reordered, because we copied before sorting', originalUntouched, ['Chinedu', 'Bukola', 'Amaka', 'Zainab', 'Tunde']);
test('two rows equal on every key compare as 0, which is what lets || fall through', identicalRows, 0);
test('a boolean comparator can only ever say "equal" or "after"', booleanComparator, [false, 0]);

The || chain is the whole technique. Each comparison returns a negative number, a positive number, or 0 for a tie, and 0 is the only falsy one, so || moves to the next key exactly when the current one cannot decide. Adding a third tie-break is one more line, and no nesting appears.

Pinning is done as arithmetic on purpose. (a.id === pin ? 0 : 1) - (b.id === pin ? 0 : 1) gives -1, 1 or 0, and the 0 case — neither row is pinned — falls through to the real ranking. The tempting version, if (a.id === pin) return -1;, is subtly broken: it claims the pinned row sorts before itself, which is an inconsistent comparator, and inconsistent comparators produce implementation-defined orders rather than errors.

The last assertion is why comparators return numbers. a > b gives true or false, which coerce to 1 and 0 — there is no way to express "a comes first", so half the information the sort needs never arrives. The result is not random, it is engine-specific, which is worse: it passes in one browser and reorders rows in another. sort mutating in place is the other half of the sixth assertion. It returns the same array it was handed, so const sorted = rows.sort(...) silently reorders the caller's data and hands back an alias, not a copy.

84 A debounced search box, and how many calls got through

fair

Wrap a search function so that a burst of keystrokes fires it once, after typing stops, with the last value. Drive it with a clock you control so you can assert exactly how many requests went out and what each one carried. Add a cancel for the component unmounting mid-wait.

Stuck? Three hints, smallest first
  1. 1. Keep the pending timer id in a closure. Every call clears the previous one before setting a new one.
  2. 2. Take the clock as an argument — { setTimeout, clearTimeout, advance } — so the test never waits.
  3. 3. Hang cancel off the returned function; it clears the timer and forgets it.

The answer

// A clock driven by hand. Nothing here sleeps.
function makeClock() {
  let now = 0;
  let seq = 0;
  const jobs = new Map();
  return {
    setTimeout(fn, ms) { jobs.set(++seq, { at: now + ms, fn }); return seq; },
    clearTimeout(id) { jobs.delete(id); },
    advance(ms) {
      now += ms;
      const due = [...jobs].filter(([, j]) => j.at <= now).sort((a, b) => a[1].at - b[1].at);
      for (const [id, job] of due) { jobs.delete(id); job.fn(); }
    },
    get time() { return now; },
    get pending() { return jobs.size; },
  };
}

function debounce(fn, wait, clock) {
  let timer = null;
  const wrapped = (...args) => {
    if (timer !== null) clock.clearTimeout(timer);
    timer = clock.setTimeout(() => { timer = null; fn(...args); }, wait);
  };
  wrapped.cancel = () => { if (timer !== null) { clock.clearTimeout(timer); timer = null; } };
  return wrapped;
}

const clock = makeClock();
const requests = [];
const search = debounce((q) => requests.push(q), 100, clock);

// Someone types "lag" at 30ms a keystroke.
search('l');
clock.advance(30);
search('la');
clock.advance(30);
search('lag');
clock.advance(30);
const duringTheBurst = requests.length;

clock.advance(100);                 // typing stopped, the last timer comes due
const afterTheBurst = [...requests];

// A second, separate burst.
search('ikeja');
clock.advance(150);

// And a call abandoned before it could fire.
search('yaba');
search.cancel();
clock.advance(500);

console.log('during the burst :', duringTheBurst, 'requests');
console.log('after it settled :', afterTheBurst);
console.log('in the end       :', requests, 'at t =', clock.time + 'ms');

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('three keystrokes in quick succession sent nothing at all', duringTheBurst, 0);
test('once typing stopped, exactly one request went out, carrying the last value', afterTheBurst, ['lag']);
test('a later burst gets its own request', requests, ['lag', 'ikeja']);
test('so five calls produced two requests', requests.length, 2);
test('cancel discards the pending one instead of firing it late', requests.includes('yaba'), false);
test('and leaves no timer behind', clock.pending, 0);
test('all of that happened in 840ms of simulated time and none of real time', clock.time, 840);

The reason to inject the clock is in the assertions: five calls, two requests, and the exact payload of each. With the real setTimeout this exercise could only be watched, and the interesting case — a keystroke arriving 1ms before the timer was due — is not stageable at all.

Debounce and throttle are different promises and the difference matters. Debounce says "run once, after the noise stops", which is what a search box wants; it can wait for ever under continuous input. Throttle says "run at most once per interval", which is what a scroll handler wants. Reaching for the wrong one gives you either a search that never fires or a request per keystroke.

cancel is not a nicety. In a component that unmounts while a debounce is pending, the callback still runs and writes to state that no longer exists, which is the classic "cannot update an unmounted component" warning. The clean-up is one clearTimeout, and the fifth and sixth assertions are what proves it happened.

85 A token bucket that allows a burst and then meters you

hard

Build a rate limiter that lets a client send a short burst immediately and then holds it to a steady rate: a bucket of tokens, one per message, refilled continuously and never past its capacity. Take the clock as an argument. Show that idling for a long time does not bank unlimited credit, and that a request larger than the bucket can never be served.

Stuck? Three hints, smallest first
  1. 1. Store tokens as a fraction, not an integer. The refill is elapsed / 1000 * ratePerSecond.
  2. 2. Refill lazily, at the top of every take, from the time elapsed since the last refill. No interval needed.
  3. 3. Math.min(capacity, tokens + earned) is the whole difference between a burst allowance and an unlimited one.

The answer

function makeClock() {
  let now = 0;
  return { now: () => now, advance(ms) { now += ms; }, get time() { return now; } };
}

function tokenBucket({ capacity, perSecond, clock }) {
  let tokens = capacity;                 // start full, so a first burst is allowed
  let last = clock.now();
  const refill = () => {
    const at = clock.now();
    tokens = Math.min(capacity, tokens + ((at - last) / 1000) * perSecond);
    last = at;
  };
  return {
    take(cost = 1) {
      refill();
      if (tokens < cost) return false;
      tokens -= cost;
      return true;
    },
    get available() { refill(); return Math.round(tokens * 100) / 100; },
  };
}

const clock = makeClock();
const sms = tokenBucket({ capacity: 3, perSecond: 2, clock });   // burst of 3, then 1 every 500ms

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

clock.advance(500);                    // half a second earns exactly one token
const afterHalfASecond = sms.available;
const fifth = sms.take();

clock.advance(10000);                  // idle for ten seconds
const cappedAtCapacity = sms.available;
const tooBigForTheBucket = sms.take(4);
const stillFull = sms.available;

const pairAtOnce = sms.take(2);
const leftOver = sms.available;

// Ten attempts back to back from a fresh bucket.
const fresh = tokenBucket({ capacity: 3, perSecond: 2, clock });
let accepted = 0;
for (let i = 0; i < 10; i++) if (fresh.take()) accepted++;

console.log('burst of three   :', burst, '| fourth:', fourth);
console.log('after 500ms      :', afterHalfASecond, 'token(s)');
console.log('after 10s idle   :', cappedAtCapacity, '<- capped, not 20');
console.log('ten rapid tries  :', accepted, 'accepted');

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 bucket starts full, so a burst up to capacity goes straight through', burst, [true, true, true]);
test('the message after that is refused', fourth, false);
test('with the bucket empty', emptied, 0);
test('500ms at two per second earns exactly one token, and it is spendable', [afterHalfASecond, fifth], [1, true]);
test('ten idle seconds do not bank twenty tokens; the capacity is the ceiling', cappedAtCapacity, 3);
test('a request costing more than the whole bucket can never be served', [tooBigForTheBucket, stillFull], [false, 3]);
test('but a cost of two is served from a full bucket, leaving one', [pairAtOnce, leftOver], [true, 1]);
test('and ten back-to-back attempts get exactly the burst allowance', accepted, 3);

A token bucket is the limiter to reach for when traffic is bursty but the average must be held down, which describes almost every real client. Capacity is the burst you tolerate; the refill rate is the sustained throughput. A fixed window — "100 per minute" — is easier to write and allows 200 requests across a window boundary, and a leaky bucket smooths output but cannot express a burst at all.

Refilling lazily is the part worth copying. There is no interval, no background work and nothing to clean up: each call works out how much time has passed and credits that fraction of a token. A bucket nobody touches costs nothing, which matters when there is one per phone number.

Math.min(capacity, ...) is the entire safety property, and the fifth assertion is what it buys. Without it, a client idle overnight arrives with tens of thousands of tokens and can empty your SMS budget in one loop. The sixth assertion is the other edge: a cost above capacity is not "wait longer", it is never satisfiable, so it needs rejecting at the point of validation rather than being left to retry for ever.

86 A de-duplicator that hands back the request already in flight

hard

Three components mount at once and all three ask for the same vendor list. Wrap the loader so that the second and third callers receive the same promise as the first rather than starting their own request. Forget the key once it settles, so this stays a de-duplicator and not a cache, and make sure a failure is shared and then cleared too.

Stuck? Three hints, smallest first
  1. 1. A Map from key to the promise currently in flight is the whole data structure.
  2. 2. Return the stored promise if there is one. Only create a new one when there is not.
  3. 3. .finally() is where the key gets deleted, because it runs for a rejection as well as a resolution.

The answer

function deduplicate(loader) {
  const inFlight = new Map();
  return {
    load(key) {
      const running = inFlight.get(key);
      if (running) return running;                     // the same promise object, not a copy
      // The async wrapper calls loader synchronously and still turns a
      // synchronous throw into a rejection.
      const started = (async () => loader(key))().finally(() => inFlight.delete(key));
      inFlight.set(key, started);
      return started;
    },
    get size() { return inFlight.size; },
  };
}

// A loader we can settle by hand, so nothing depends on timing.
const gates = new Map();
const asked = [];
const loader = (key) => {
  asked.push(key);
  return new Promise((resolve, reject) => gates.set(key, { resolve, reject }));
};

const api = deduplicate(loader);

const a = api.load('vendors?city=lagos');
const b = api.load('vendors?city=lagos');
const c = api.load('vendors?city=abuja');
const sharedPromise = a === b;
const inFlightNow = api.size;
const requestsSoFar = asked.length;

gates.get('vendors?city=lagos').resolve(['Adaeze Fabrics']);
gates.get('vendors?city=abuja').resolve(['Chidi Electronics']);
const [fromA, fromB, fromC] = await Promise.all([a, b, c]);
const clearedAfterSettling = api.size;

// Settled means forgotten, so this really does go out again.
const again = api.load('vendors?city=lagos');
gates.get('vendors?city=lagos').resolve(['Adaeze Fabrics', 'Balogun Textiles']);
const secondAnswer = await again;

// A failure is shared by every waiter, and evicted just the same.
const failing = api.load('vendors?city=kano');
const alsoFailing = api.load('vendors?city=kano');
gates.get('vendors?city=kano').reject(new Error('504 from upstream'));
const bothErrors = await Promise.all([failing.catch((e) => e.message), alsoFailing.catch((e) => e.message)]);
const clearedAfterFailing = api.size;

const retried = api.load('vendors?city=kano');
gates.get('vendors?city=kano').resolve(['Kano Traders']);
const afterRetry = await retried;

console.log('same promise?   :', sharedPromise);
console.log('requests made   :', asked);
console.log('shared failure  :', bothErrors);

Output


      
8 checks that prove this answer

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

8 checks

test('two callers for one key hold the very same promise object', sharedPromise, true);
test('so three callers across two keys made two requests, both in flight', [requestsSoFar, inFlightNow], [2, 2]);
test('and the duplicate caller gets the identical value, not a second copy', [fromA === fromB, fromA, fromC], [true, ['Adaeze Fabrics'], ['Chidi Electronics']]);
test('a settled key is forgotten, so the map empties itself', clearedAfterSettling, 0);
test('which makes this a de-duplicator, not a cache: the next call really refetches', secondAnswer, ['Adaeze Fabrics', 'Balogun Textiles']);
test('a rejection reaches every waiter with the same message', bothErrors, ['504 from upstream', '504 from upstream']);
test('and finally() clears the key for a failure too, so a retry is possible', [clearedAfterFailing, afterRetry], [0, ['Kano Traders']]);
test('every request the loader was actually asked for, in order', asked, ['vendors?city=lagos', 'vendors?city=abuja', 'vendors?city=lagos', 'vendors?city=kano', 'vendors?city=kano']);

This is the smallest fix for the "three components, three identical requests" problem, and the second assertion is the measure of it: three load calls, two requests. It works because a promise is a value you can hand round — the second caller does not need its own request, it needs a reference to the one already running.

.finally() rather than .then() for the eviction is deliberate, and the seventh assertion is why. Clearing the key only on success leaves a rejected promise wedged in the map for ever, so every later caller instantly re-receives the same old error and the failure becomes permanent. A shared failure is correct; a cached one is not.

Note what this is not. There is no caching here at all: once the request settles, the key is gone, and the fifth assertion proves the next call reaches the loader. That separation is worth keeping — de-duplication is about concurrency, caching is about time, and the two have completely different invalidation rules. Exercise 90 does the caching half. The (async () => loader(key))() wrapper earns its keep on one edge: a loader that validates its arguments and throws synchronously. Called bare, that throw escapes load as an exception rather than a rejection, and the key is left in the map because .finally was never attached.

87 An async pool with a concurrency ceiling

hard

Run a list of async jobs with at most n in flight at once, and return the results in input order rather than completion order. Prove the ceiling held by watching how many were running at the peak. Then find out what happens to the jobs already in flight when one of them rejects.

Stuck? Three hints, smallest first
  1. 1. Start exactly n workers. Each one pulls the next index off a shared counter until the list runs out.
  2. 2. Write the result into results[i], so ordering comes from the index and not from who finished first.
  3. 3. Promise.all of the workers is what tells you the pool is drained.

The answer

async function pool(jobs, limit) {
  const results = new Array(jobs.length);
  let next = 0;
  const worker = async () => {
    while (next < jobs.length) {
      const index = next++;                 // claim it before awaiting, or two workers take it
      results[index] = await jobs[index]();
    }
  };
  const workers = Array.from({ length: Math.min(limit, jobs.length) }, worker);
  await Promise.all(workers);
  return results;
}

// Jobs that record when they start and finish, and how many overlapped.
function traced(names, holds = 2) {
  const log = { started: [], finished: [], peak: 0 };
  let running = 0;
  const jobs = names.map((name) => async () => {
    running++;
    log.peak = Math.max(log.peak, running);
    log.started.push(name);
    for (let i = 0; i < holds; i++) await Promise.resolve();
    running--;
    log.finished.push(name);
    return name.toUpperCase();
  });
  return { jobs, log };
}

const branches = ['ikeja', 'yaba', 'surulere', 'lekki', 'ojota', 'apapa'];

const twoAtATime = traced(branches);
const results = await pool(twoAtATime.jobs, 2);

const serial = traced(branches);
await pool(serial.jobs, 1);

const wideOpen = traced(['ikeja', 'yaba', 'surulere']);
await pool(wideOpen.jobs, 10);

const nothing = await pool([], 4);

// One job rejects while others are already in flight.
const flaky = traced(['ikeja', 'yaba', 'surulere']);
flaky.jobs[1] = async () => { throw new Error('upload failed for yaba'); };
const outcome = await pool(flaky.jobs, 2).then(() => 'resolved', (err) => err.message);
for (let drain = 0; drain < 40; drain++) await Promise.resolve();
const finishedAnyway = flaky.log.finished;

console.log('results        :', results);
console.log('peak of 2      :', twoAtATime.peak, twoAtATime.log.peak);
console.log('serial started :', serial.log.started);
console.log('after a failure:', outcome, '| still finished:', finishedAnyway);

Output


      
8 checks that prove this answer

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

8 checks

test('results come back in input order, whoever finished first', results, ['IKEJA', 'YABA', 'SURULERE', 'LEKKI', 'OJOTA', 'APAPA']);
test('with a ceiling of two, two ran at the peak and never three', twoAtATime.log.peak, 2);
test('and the first two started before either of them finished', twoAtATime.log.started.slice(0, 2), ['ikeja', 'yaba']);
test('a ceiling of one serialises it completely', [serial.log.peak, serial.log.finished], [1, branches]);
test('a ceiling above the list size starts everything and creates no idle workers', [wideOpen.log.peak, wideOpen.log.started], [3, ['ikeja', 'yaba', 'surulere']]);
test('an empty list resolves to an empty array instead of hanging', nothing, []);
test('one rejection is what the pool reports, and it reports the first one', outcome, 'upload failed for yaba');
test('but the work already claimed carried on regardless: rejecting is not cancelling', finishedAnyway, ['ikeja', 'surulere']);

Promise.all(jobs.map(run)) starts everything at once, which is fine for six jobs and fatal for six thousand. The fix is not to chunk the list — chunking makes every batch wait for its slowest member — but to start a fixed number of workers that each pull from a shared cursor. Throughput stays at the ceiling instead of sagging at every batch boundary.

const index = next++ before the await is the line that makes it correct. Read the counter, then increment, then await: two workers resuming in the same turn would otherwise claim the same index, and one job would run twice while another never ran at all.

The last two assertions are the trap. Promise.all rejects as soon as one worker rejects, so await pool(...) throws while the other worker is still mid-job — and it keeps going, because nothing in JavaScript cancels a promise. The started work finishes, its results are written into an array nobody is holding, and any side effects it has still happen. If that matters, thread an AbortSignal through the jobs, or collect settled outcomes per job instead of letting the first failure speak for the whole batch.

88 A retry that gives up, and reports every attempt it made

hard

Retry a failing call with doubling waits, capped at a maximum, and when it runs out of attempts report rather than throw: every attempt, its error, and the delay that followed it. Let the caller mark some errors as not worth retrying, so a 400 stops immediately while a 503 does not. The clock is an argument.

Stuck? Three hints, smallest first
  1. 1. Push a record per attempt: { attempt, error, delay }. The last attempt has no delay after it.
  2. 2. Math.min(maxMs, baseMs * 2 ** (attempt - 1)) is the capped backoff.
  3. 3. A retryable predicate decides whether to keep going. Give it a default of "always" so the simple case stays simple.

The answer

function makeClock() {
  let now = 0;
  const jobs = [];
  const drain = async () => { for (let turn = 0; turn < 20; turn++) await Promise.resolve(); };
  return {
    wait(ms) { return new Promise((resolve) => jobs.push({ at: now + ms, resolve })); },
    async advance(ms) {
      // Drain FIRST, then move the clock. Anything mid-attempt has not registered
      // its wait yet, and a wait registered after the clock moved would be timed
      // from the wrong "now" and never come due.
      await drain();
      now += ms;
      for (const job of jobs.filter((j) => j.at <= now)) {
        jobs.splice(jobs.indexOf(job), 1);
        job.resolve();
      }
      await drain();
    },
    get time() { return now; },
  };
}

async function retry(work, { attempts = 4, baseMs = 200, maxMs = 1000, retryable = () => true, clock }) {
  const tries = [];
  let outcome = { ok: false, tries, reason: 'never ran' };
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      const value = await work(attempt);
      outcome = { ok: true, value, attempt, tries };
      break;
    } catch (err) {
      const lastChance = attempt === attempts;
      const stop = lastChance || !retryable(err);
      const delay = stop ? null : Math.min(maxMs, baseMs * 2 ** (attempt - 1));
      tries.push({ attempt, error: err.message, delay });
      if (stop) {
        outcome = { ok: false, tries, reason: lastChance ? 'out of attempts' : 'not retryable' };
        break;
      }
      await clock.wait(delay);
    }
  }
  return outcome;
}

// 1. Always fails, five attempts, so the cap gets exercised.
const clockA = makeClock();
let callsA = 0;
const alwaysDown = async () => { callsA++; throw new Error('503 from paystack'); };
const runA = retry(alwaysDown, { attempts: 5, baseMs: 200, maxMs: 1000, clock: clockA });
await clockA.advance(200);
await clockA.advance(400);
await clockA.advance(800);
await clockA.advance(1000);
const gaveUp = await runA;
const delays = gaveUp.tries.map((t) => t.delay);
const uncappedFourth = 200 * 2 ** 3;

// 2. A 503 then a 400. The 400 is not worth retrying.
const clockB = makeClock();
let callsB = 0;
const thenRejected = async (attempt) => {
  callsB++;
  throw new Error(attempt === 1 ? '503 service unavailable' : '400 invalid account number');
};
const runB = retry(thenRejected, { attempts: 5, baseMs: 200, clock: clockB, retryable: (e) => e.message.startsWith('5') });
await clockB.advance(200);
const bailedOut = await runB;

// 3. Fails twice, then works.
const clockC = makeClock();
const flaky = async (attempt) => {
  if (attempt < 3) throw new Error('503 from paystack');
  return 'receipt/NG-4417';
};
const runC = retry(flaky, { baseMs: 200, clock: clockC });
await clockC.advance(200);
await clockC.advance(400);
const eventually = await runC;

console.log('gave up      :', gaveUp.reason, '| delays', delays);
console.log('bailed out   :', bailedOut.reason, 'after', bailedOut.tries.length, 'attempts');
console.log('eventually   :', eventually.value, 'on attempt', eventually.attempt);

Output


      
8 checks that prove this answer

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

8 checks

test('it gave up and said which way it gave up', [gaveUp.ok, gaveUp.reason], [false, 'out of attempts']);
test('every attempt is reported with the delay that followed it, the last one with none', delays, [200, 400, 800, 1000, null]);
test('the fourth delay is the cap, not the doubling', [delays[3], uncappedFourth], [1000, 1600]);
test('five attempts means five calls and 2400ms of simulated waiting', [callsA, clockA.time], [5, 2400]);
test('a non-retryable error stops it early, with a different reason', [bailedOut.reason, callsB], ['not retryable', 2]);
test('so only the first failure was followed by a wait', bailedOut.tries.map((t) => t.delay), [200, null]);
test('the success path says which attempt worked and what it waited for first', [eventually.ok, eventually.value, eventually.attempt], [true, 'receipt/NG-4417', 3]);
test('and carries the failures it survived on the way', eventually.tries.map((t) => t.delay), [200, 400]);

Returning an outcome instead of throwing is the design choice here, and the point of it is the tries array. A retry that rethrows the last error tells you a call failed; this tells you it failed five times over 2.4 seconds with the same 503, which is the difference between guessing and knowing. Log that array once, at the boundary, rather than a line per attempt.

The cap matters more than the doubling. Uncapped exponential backoff reaches half a minute by attempt eight, and a user has long since given up and pressed the button again; the third assertion is the cap doing its job where the raw doubling would have said 1600ms. Real deployments add jitter on top — a random fraction of each delay — because otherwise every client that failed in the same instant retries in the same instant, and a brief wobble becomes a stampede. It is left out here only because this page has to be deterministic.

The retryable predicate is the part most retry helpers omit and then regret. Retrying a 400, a 401 or a validation failure cannot possibly help: the request is wrong and will still be wrong in 800ms. Worse, retrying a non-idempotent POST that actually succeeded but timed out is how one order becomes three. Retry on timeouts, connection resets and 5xx; report everything else on the first failure.

89 A circuit breaker: closed, open, and the one trial call

hard

Stop hammering a service that is already down. Count consecutive failures, and after a threshold refuse calls outright for a cooldown without touching the upstream at all. When the cooldown elapses, let exactly one trial call through: if it works, close the circuit; if it fails, open it again for another cooldown. The clock is injected, and a success anywhere resets the failure count.

Stuck? Three hints, smallest first
  1. 1. Three pieces of state: the phase, the consecutive failure count, and the time it opened.
  2. 2. Half-open is not a separate timer. It is what "open" means once now - openedAt has reached the cooldown.
  3. 3. A failure during the trial must re-open the circuit and reset openedAt, or the next call gets waved straight through.

The answer

function makeClock() {
  let now = 0;
  return { now: () => now, advance(ms) { now += ms; } };
}

function circuitBreaker({ threshold = 3, cooldownMs = 1000, clock }) {
  let phase = 'closed';
  let failures = 0;
  let openedAt = 0;
  let refused = 0;

  // Half-open is a derived phase, not a stored one.
  const view = () =>
    phase === 'open' && clock.now() - openedAt >= cooldownMs ? 'half-open' : phase;

  return {
    get state() { return view(); },
    get refused() { return refused; },
    get failures() { return failures; },
    async call(work) {
      const at = view();
      if (at === 'open') {
        refused++;
        throw new Error('circuit open, upstream not called');
      }
      try {
        const value = await work();
        failures = 0;
        phase = 'closed';
        return value;
      } catch (err) {
        failures++;
        if (at === 'half-open' || failures >= threshold) {
          phase = 'open';
          openedAt = clock.now();
          failures = 0;
        }
        throw err;
      }
    },
  };
}

const clock = makeClock();
let upstreamCalls = 0;
let mode = 'down';
const upstream = async () => {
  upstreamCalls++;
  if (mode === 'down') throw new Error('503 from the bank');
  return 'balance: 148250 kobo';
};

const cb = circuitBreaker({ threshold: 3, cooldownMs: 1000, clock });
const attempt = async () => { try { return await cb.call(upstream); } catch (err) { return err.message; } };

const firstThree = [await attempt(), await attempt(), await attempt()];
const openedState = cb.state;
const callsWhenItOpened = upstreamCalls;

const whileOpen = await attempt();
const untouchedUpstream = upstreamCalls;

clock.advance(1000);
const beforeTheTrial = cb.state;
const failedTrial = await attempt();
const afterFailedTrial = cb.state;

clock.advance(999);
const stillOpen = cb.state;
clock.advance(1);
mode = 'up';
const passedTrial = await attempt();
const afterPassedTrial = cb.state;

// Two failures, then a success, then two more: the count was reset in between.
mode = 'down';
await attempt();
await attempt();
mode = 'up';
await attempt();
mode = 'down';
await attempt();
await attempt();
const stillClosed = [cb.state, cb.failures];

console.log('first three   :', firstThree[2], '-> state', openedState);
console.log('while open    :', whileOpen, '| upstream calls still', untouchedUpstream);
console.log('after cooldown:', beforeTheTrial, '-> trial says', failedTrial);
console.log('trial passes  :', passedTrial, '-> state', afterPassedTrial);

Output


      
8 checks that prove this answer

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

8 checks

test('three consecutive failures open the circuit', [firstThree[2], openedState], ['503 from the bank', 'open']);
test('while it is open the call fails fast and the upstream is never touched', [whileOpen, untouchedUpstream, callsWhenItOpened], ['circuit open, upstream not called', 3, 3]);
test('and it counts what it refused, which is the number worth alerting on', cb.refused, 1);
test('once the cooldown has elapsed the phase is half-open, waiting for one trial', beforeTheTrial, 'half-open');
test('a failed trial re-opens it immediately and restarts the cooldown', [failedTrial, afterFailedTrial, stillOpen], ['503 from the bank', 'open', 'open']);
test('a passing trial closes it and the caller gets the real answer', [passedTrial, afterPassedTrial], ['balance: 148250 kobo', 'closed']);
test('a success resets the count, so two later failures do not reach the threshold', stillClosed, ['closed', 2]);
test('across all of that the upstream was called ten times and refused once', [upstreamCalls, cb.refused], [10, 1]);

The state machine is the whole exercise, and the fourth assertion is the piece people leave out. Half-open is not a third timer, it is a question asked at call time: the circuit is open, but has the cooldown elapsed? Deriving it means there is nothing to schedule, nothing to clean up, and no window where a stale timer disagrees with the stored phase.

The second assertion is the reason breakers exist. Once open, the caller fails in microseconds without a socket, a DNS lookup or a 30-second timeout — which is what stops a slow dependency from exhausting your connection pool and turning one sick service into an outage. Failing fast is the feature; the refusal count is the signal you page on.

Two edges to get right. A failure during the trial must reset openedAt, or the next call sees a cooldown that expired long ago and the breaker degenerates into no breaker at all. And the count is of consecutive failures: the last assertion shows a success wiping it, which is what keeps an occasional unrelated error from opening a circuit over the course of a day. What this version does not do is decide what to serve instead. A breaker is only half an answer — the other half is the fallback, whether that is a cached balance, a queued write or an honest "try again shortly". Throwing faster is not the same as degrading gracefully.

90 A cache that expires, on a clock you control

fair

Cache exchange rates for a fixed time to live. A read inside the window is served from memory; a read after it goes back to the loader. Use an injected clock, expire lazily on read, and then look at two things people get wrong: what the map still holds after an entry has expired, and what happens when the value you cached is undefined.

Stuck? Three hints, smallest first
  1. 1. Store { value, expiresAt }, where expiresAt is clock.now() + ttlMs.
  2. 2. Expire on read: if the entry is stale, delete it and report a miss. No timer required.
  3. 3. get returning undefined for both a miss and a stored undefined is why has needs its own answer.

The answer

function makeClock() {
  let now = 0;
  return { now: () => now, advance(ms) { now += ms; } };
}

function ttlCache({ ttlMs, clock }) {
  const entries = new Map();
  const fresh = (entry) => entry !== undefined && entry.expiresAt > clock.now();
  return {
    set(key, value) {
      entries.set(key, { value, expiresAt: clock.now() + ttlMs });
      return value;
    },
    get(key) {
      const entry = entries.get(key);
      if (fresh(entry)) return entry.value;
      entries.delete(key);                     // lazy eviction, on the read that noticed
      return undefined;
    },
    has(key) { return fresh(entries.get(key)); },
    getOrSet(key, load) {
      const entry = entries.get(key);
      if (fresh(entry)) return entry.value;
      return this.set(key, load(key));
    },
    get rawSize() { return entries.size; },
  };
}

const clock = makeClock();
const rates = ttlCache({ ttlMs: 500, clock });

rates.set('NGN/USD', 1650);
const straightAway = rates.get('NGN/USD');

clock.advance(499);
const justInsideTheWindow = rates.get('NGN/USD');

clock.advance(1);
const sizeBeforeAnyoneLooked = rates.rawSize;
const onceExpired = rates.get('NGN/USD');
const sizeAfterTheRead = rates.rawSize;

let loads = 0;
const load = (key) => { loads++; return key === 'NGN/GBP' ? 2090 : 0; };

const firstLoad = rates.getOrSet('NGN/GBP', load);
const secondLoad = rates.getOrSet('NGN/GBP', load);
const loadsInsideTheWindow = loads;

clock.advance(500);
const afterExpiry = rates.getOrSet('NGN/GBP', load);
const loadsAfterExpiry = loads;

// A cached zero is a real value; a cached undefined is not distinguishable.
const zero = rates.getOrSet('NGN/XOF', load);
rates.set('NGN/GHS', undefined);
const storedUndefined = [rates.get('NGN/GHS'), rates.has('NGN/GHS'), rates.get('never/cached'), rates.has('never/cached')];

console.log('straight away  :', straightAway);
console.log('at 499ms       :', justInsideTheWindow, '| at 500ms:', onceExpired);
console.log('loads          :', loads, 'for three distinct keys');
console.log('undefined vs miss:', storedUndefined);

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 value set is a value served', straightAway, 1650);
test('one millisecond before the ttl it is still a hit', justInsideTheWindow, 1650);
test('at exactly the ttl it is a miss', onceExpired, undefined);
test('the stale entry was still sitting in the map until a read noticed it', [sizeBeforeAnyoneLooked, sizeAfterTheRead], [1, 0]);
test('getOrSet loads once and serves the rest of the window from memory', [firstLoad, secondLoad, loadsInsideTheWindow], [2090, 2090, 1]);
test('and loads again once the window has passed', [afterExpiry, loadsAfterExpiry], [2090, 2]);
test('a cached zero is a hit, not a miss, because freshness is checked not truthiness', [zero, rates.has('NGN/XOF')], [0, true]);
test('but a stored undefined reads exactly like a key that was never cached', storedUndefined, [undefined, true, undefined, false]);

Lazy expiry is the right default: no intervals, no timers per key, nothing to unsubscribe, and an entry costs memory rather than CPU while it waits to be noticed. The fourth assertion is the price — a stale entry stays in the map until something reads it, so a cache of keys nobody revisits grows for ever. That is what a maximum size and an eviction policy are for, and an LRU is the usual pairing.

The seventh assertion is the bug hiding in almost every hand-rolled cache: if (cached) treats a cached 0, '' or false as a miss and reloads it every single time. Checking the entry for freshness rather than the value for truthiness is what fixes it, and it costs nothing.

The last assertion is the honest limitation. get has one channel to answer on, so a stored undefined and a key that was never there both come back as undefined; only has can tell them apart. If your loader can legitimately return undefined or null — a lookup for a customer who does not exist, which is exactly the result worth caching — then get must return { hit, value } or you will hammer the upstream for every negative answer.

91 A router that matches paths and captures parameters

fair

Match a URL against a list of patterns like /vendors/:id/orders/:orderId and return which route matched plus the captured parameters. Handle a trailing slash, a percent-encoded segment, a query string, a *rest wildcard that swallows everything left, and a URL that matches nothing. Then show why a static route has to be declared before the dynamic one that would also match it.

Stuck? Three hints, smallest first
  1. 1. Split both the pattern and the path on / and drop the empty pieces with filter(Boolean).
  2. 2. A segment starting with : captures; anything else must match exactly.
  3. 3. First match wins, so the order of the route list is part of its meaning.

The answer

function createRouter(routes) {
  const compiled = routes.map((route) => ({ ...route, parts: route.path.split('/').filter(Boolean) }));

  const matchParts = (parts, segments) => {
    const params = {};
    for (let i = 0; i < parts.length; i++) {
      const part = parts[i];
      if (part.startsWith('*')) {                       // swallow the remainder, slashes and all
        params[part.slice(1)] = segments.slice(i).map(decodeURIComponent).join('/');
        return params;
      }
      if (i >= segments.length) return null;            // pattern longer than the path
      if (part.startsWith(':')) {
        params[part.slice(1)] = decodeURIComponent(segments[i]);
        continue;
      }
      if (part !== segments[i]) return null;
    }
    return parts.length === segments.length ? params : null;
  };

  return function match(url) {
    const [pathname, search = ''] = url.split('?');
    const segments = pathname.split('/').filter(Boolean);
    for (const route of compiled) {
      const params = matchParts(route.parts, segments);
      if (params !== null) return { name: route.name, params, search };
    }
    return null;
  };
}

const match = createRouter([
  { name: 'home', path: '/' },
  { name: 'newVendor', path: '/vendors/new' },        // must come before /vendors/:id
  { name: 'vendor', path: '/vendors/:id' },
  { name: 'vendorOrder', path: '/vendors/:id/orders/:orderId' },
  { name: 'files', path: '/files/*rest' },
]);

const root = match('/');
const one = match('/vendors/42');
const trailingSlash = match('/vendors/42/');
const staticWins = match('/vendors/new');
const encoded = match('/vendors/Adaeze%20Fabrics');
const nested = match('/vendors/42/orders/ORD-001');
const withQuery = match('/vendors/42?tab=orders&page=2');
const unmatched = match('/nothing/here');
const wildcard = match('/files/2025/lagos/invoice%20final.pdf');

console.log('/vendors/42            :', one.name, one.params);
console.log('/vendors/new           :', staticWins.name, '<- static route declared first');
console.log('/vendors/42?tab=orders :', withQuery.params, 'search:', withQuery.search);
console.log('/files/...             :', wildcard.params);

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 root matches with no parameters', root, { name: 'home', params: {}, search: '' });
test('a :segment is captured under its name, trailing slash or not', [one.name, one.params, trailingSlash.params], ['vendor', { id: '42' }, { id: '42' }]);
test('the static route wins because it is declared first, so /vendors/new is not an id', [staticWins.name, staticWins.params], ['newVendor', {}]);
test('captured segments are percent-decoded', encoded.params, { id: 'Adaeze Fabrics' });
test('two parameters in one pattern', [nested.name, nested.params], ['vendorOrder', { id: '42', orderId: 'ORD-001' }]);
test('the query string plays no part in matching and comes back separately', [withQuery.name, withQuery.params, withQuery.search], ['vendor', { id: '42' }, 'tab=orders&page=2']);
test('nothing matching gives null, which is the caller\'s 404, not a throw', unmatched, null);
test('a *wildcard takes everything left, slashes included, still decoded', wildcard.params, { rest: '2025/lagos/invoice final.pdf' });

Two lines carry the whole matcher. filter(Boolean) after the split is what makes /vendors/42 and /vendors/42/ the same route and stops the leading slash producing a phantom empty segment. And the final parts.length === segments.length check is what stops /vendors/:id matching /vendors/42/orders/1 — without it, a short pattern quietly swallows every longer path beneath it.

The third assertion is the ordering rule, and it is the one that bites in real apps. /vendors/new and /vendors/:id both match the same URL, so the list order decides, and putting the dynamic route first gives you a vendor page for the id "new". Real routers avoid the trap by ranking specificity — static beats dynamic beats wildcard — rather than trusting declaration order. Knowing which of the two your router does is the difference between a working form and a mystifying 404.

Decoding belongs in the matcher, not in the page. /vendors/Adaeze%20Fabrics arrives encoded, and a page that renders the raw segment shows the percent sign to the user. Decode once, where the capture happens, and note that a segment containing an encoded slash — %2F — becomes a slash after decoding, which is exactly why the split happens first and the decode second.

92 A form reducer that tracks dirty fields and hides errors until asked

fair

Write a pure reducer for one form: change, blur, submit and reset. Validate on every change, but only show an error for a field the user has touched, and reveal all of them on submit. Report which fields differ from the values the form opened with, and make sure typing a value back to its original clears that.

Stuck? Three hints, smallest first
  1. 1. Keep the values it opened with alongside the current ones. Dirty is a comparison, not a flag you set.
  2. 2. Validate the whole values object on every change; decide separately what to display.
  3. 3. A reducer returns a new object for a change it understood and the same object for one it did not.

The answer

const validate = (values) => {
  const errors = {};
  if (values.fullName.trim() === '') errors.fullName = 'Name is required';
  if (!/^0\d{10}$/.test(values.phone)) errors.phone = 'Enter an 11-digit number, e.g. 08031234567';
  if (!(values.amountKobo > 0)) errors.amountKobo = 'Enter an amount';
  return errors;
};

const createForm = (values) => ({
  values,
  opened: values,
  touched: {},
  errors: validate(values),
  submitted: false,
});

function reduce(state, action) {
  switch (action.type) {
    case 'change': {
      const values = { ...state.values, [action.field]: action.value };
      return { ...state, values, errors: validate(values) };
    }
    case 'blur':
      return { ...state, touched: { ...state.touched, [action.field]: true } };
    case 'submit': {
      const touched = Object.fromEntries(Object.keys(state.values).map((field) => [field, true]));
      return { ...state, touched, submitted: true };
    }
    case 'reset':
      return createForm(state.opened);
    default:
      return state;                       // unknown action: the very same object back
  }
}

const dirtyFields = (state) => Object.keys(state.values).filter((f) => state.values[f] !== state.opened[f]);
const shownErrors = (state) => Object.fromEntries(Object.entries(state.errors).filter(([f]) => state.touched[f]));
const isValid = (state) => Object.keys(state.errors).length === 0;
const drive = (state, ...actions) => actions.reduce(reduce, state);

const fresh = createForm({ fullName: '', phone: '', amountKobo: 0 });

const typed = drive(fresh, { type: 'change', field: 'fullName', value: 'Ada' });
const openedIntact = fresh.values.fullName;

const typedBack = drive(typed, { type: 'change', field: 'fullName', value: '' });

const badPhone = drive(typed, { type: 'change', field: 'phone', value: '0803123' });
const blurred = drive(badPhone, { type: 'blur', field: 'phone' });

const submitted = drive(badPhone, { type: 'submit' });

const good = drive(
  fresh,
  { type: 'change', field: 'fullName', value: 'Adaeze Okonkwo' },
  { type: 'change', field: 'phone', value: '08031234567' },
  { type: 'change', field: 'amountKobo', value: 250000 },
  { type: 'submit' }
);

const afterReset = drive(good, { type: 'reset' });
const ignored = reduce(good, { type: 'nothing-we-know' });

console.log('fresh    : dirty', dirtyFields(fresh), 'shown', shownErrors(fresh), 'valid', isValid(fresh));
console.log('typed    : dirty', dirtyFields(typed));
console.log('blurred  : shown', shownErrors(blurred));
console.log('submitted: shown', Object.keys(shownErrors(submitted)));

Output


      
9 checks that prove this answer

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

9 checks

test('a form that just opened is clean and shows nothing, though it is not valid', [dirtyFields(fresh), shownErrors(fresh), isValid(fresh)], [[], {}, false]);
test('changing a field makes it dirty and leaves the previous state alone', [dirtyFields(typed), openedIntact], [['fullName'], '']);
test('typing the original value back clears dirty rather than latching it', dirtyFields(typedBack), []);
test('an invalid field has an error immediately but shows nothing until it is touched', [Object.keys(badPhone.errors), shownErrors(badPhone)], [['phone', 'amountKobo'], {}]);
test('blur is what reveals that one field\'s error', shownErrors(blurred), { phone: 'Enter an 11-digit number, e.g. 08031234567' });
test('submit touches everything, so every outstanding error appears at once', Object.keys(shownErrors(submitted)), ['phone', 'amountKobo']);
test('a properly filled form submits with nothing to show', [isValid(good), shownErrors(good), good.submitted], [true, {}, true]);
test('reset goes back to the values it opened with and forgets the touching', [afterReset.values, afterReset.touched, dirtyFields(afterReset)], [{ fullName: '', phone: '', amountKobo: 0 }, {}, []]);
test('an action the reducer does not know returns the identical object, so nothing re-renders', ignored === good, true);

Dirty as a derived value rather than a stored flag is the design decision worth taking away. values[f] !== opened[f] answers per field, for free, and the third assertion is what a stored flag gets wrong: a user who types a character and deletes it has changed nothing, but a dirty = true set on first keystroke never comes back down, and the "unsaved changes" prompt that follows is a lie.

Validating on every change while showing errors only for touched fields separates two things that get conflated. The state always knows whether the form is valid — which is what the submit button needs — while the display stays quiet until the user has actually visited a field. Submit is then nothing more than "touch everything", which is why one line reveals every outstanding error and why the sixth assertion works without a second validation pass.

The last assertion is the reducer contract. Returning state unchanged for an action you do not handle keeps referential equality, which is what lets a UI skip a re-render. Returning { ...state } instead is a new object every time, every component re-renders, and the bug looks like a performance problem rather than a mistake in the default case.

93 Undo and redo, and the trap of storing references

fair

Build undo/redo for an editor: a past stack, a present, and a future. A new edit after an undo must discard the redo stack, and undo at the very beginning must be a harmless no-op rather than a crash. Then mutate a state object in place and see what undo hands back.

Stuck? Three hints, smallest first
  1. 1. Three pieces: past, present, future. Undo moves one item from past to future through the present.
  2. 2. A fresh edit clears future. That is the branch you just abandoned.
  3. 3. The stacks hold whatever references you gave them, so a snapshot has to be a new object.

The answer

function createHistory(initial) {
  const past = [];
  const future = [];
  let present = initial;
  return {
    get present() { return present; },
    commit(next) {
      past.push(present);
      present = next;
      future.length = 0;             // the redo branch is abandoned by a new edit
      return present;
    },
    undo() {
      if (past.length === 0) return present;
      future.push(present);
      present = past.pop();
      return present;
    },
    redo() {
      if (future.length === 0) return present;
      past.push(present);
      present = future.pop();
      return present;
    },
    get canUndo() { return past.length > 0; },
    get canRedo() { return future.length > 0; },
    get depth() { return { past: past.length, future: future.length }; },
  };
}

const editor = createHistory({ title: 'Invoice 001', totalKobo: 0 });
const atTheStart = [editor.canUndo, editor.canRedo];
const nothingToUndo = editor.undo();

editor.commit({ title: 'Invoice 001', totalKobo: 250000 });
editor.commit({ title: 'Invoice 001 (Lagos)', totalKobo: 250000 });
editor.commit({ title: 'Invoice 001 (Lagos)', totalKobo: 412500 });
const afterThreeEdits = editor.depth;

const undoneOnce = editor.undo();
const undoneTwice = editor.undo();
const redone = editor.redo();
const flags = [editor.canUndo, editor.canRedo];

editor.undo();                              // back to the second state
editor.commit({ title: 'Invoice 001 (Ikeja)', totalKobo: 250000 });
const futureDiscarded = editor.depth;
const nothingToRedo = editor.redo();

// The trap: the stacks hold references, not copies.
const shared = { title: 'Receipt', totalKobo: 100 };
const sloppy = createHistory(shared);
shared.totalKobo = 99999;                   // mutating the object the history is holding
sloppy.commit({ title: 'Receipt', totalKobo: 200 });
const undoneAfterMutation = sloppy.undo();

console.log('after three edits :', afterThreeEdits);
console.log('undo, undo, redo  :', redone.totalKobo, 'flags', flags);
console.log('edit after undo   :', futureDiscarded, '<- future gone');
console.log('mutated snapshot  :', undoneAfterMutation);

Output


      
8 checks that prove this answer

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

8 checks

test('a fresh history cannot undo or redo, and undo is a harmless no-op', [atTheStart, nothingToUndo.totalKobo], [[false, false], 0]);
test('three edits leave three states behind and nothing ahead', afterThreeEdits, { past: 3, future: 0 });
test('undo walks backwards through the states in order', [undoneOnce.title, undoneTwice.totalKobo], ['Invoice 001 (Lagos)', 250000]);
test('redo walks forwards again', [redone.title, redone.totalKobo], ['Invoice 001 (Lagos)', 250000]);
test('with something on both sides, both flags are true', flags, [true, true]);
test('an edit after an undo discards the redo branch entirely', futureDiscarded, { past: 2, future: 0 });
test('so redo has nowhere to go and returns the present unchanged', nothingToRedo.title, 'Invoice 001 (Ikeja)');
test('and a state mutated in place is corrupted in the past too, because the stack held the reference', undoneAfterMutation, { title: 'Receipt', totalKobo: 99999 });

Past, present, future is the whole model, and it is worth writing out rather than reaching for a library. Undo is "push the present onto future, pop the past into present", redo is the mirror image, and both guard the empty case by returning the present rather than throwing — a disabled button and a no-op are the same thing to a user who pressed Ctrl+Z one time too many.

The sixth assertion is the rule people forget. Editing after an undo abandons the branch you had undone, so the future stack must be cleared; leaving it means redo jumps to a state that was never derived from the current one, and the document silently loses the edit in between.

The last assertion is the one that produces real bugs. The stacks store references, so a state object you mutate in place is the same object sitting in past, and undo faithfully returns the mutated version. Nothing warns you. Either commit fresh objects — { ...state, totalKobo: next }, which is what the reducer in exercise 92 does anyway — or freeze what you commit so an accidental write throws under strict mode instead of quietly rewriting history. What this leaves out is worth knowing: full snapshots are simple and fine for a form or a small document, and hopeless for anything large. Once a snapshot is expensive, you store the inverse operation per step instead — a command pattern — and undo applies the inverse rather than restoring a copy.

94 A basket total in kobo, with a discount and 7.5% VAT

fair

Total a basket entirely in integer kobo: line totals, a percentage discount off the subtotal, then VAT at 7.5% on what is left. Express the rates in basis points so nothing needs a fractional multiplier, round exactly once where a half-kobo appears, and format the result as naira with thousand separators. Then say why none of this is done in naira with a decimal point.

Stuck? Three hints, smallest first
  1. 1. Keep every amount an integer number of kobo. Convert to naira only when printing.
  2. 2. A percentage as basis points — 750 for 7.5% — turns × 0.075 into × 750 / 10000.
  3. 3. replace(/\B(?=(\d{3})+(?!\d))/g, ',') inserts the separators from the right.

The answer

const naira = (kobo) =>
  '\u20a6' + (kobo / 100).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');

const VAT_BPS = 750;   // 7.5%, in basis points

function totalBasket(lines, { discountBps = 0, vatBps = VAT_BPS } = {}) {
  const subtotalKobo = lines.reduce((sum, line) => sum + line.unitKobo * line.qty, 0);
  const discountKobo = Math.round((subtotalKobo * discountBps) / 10000);
  const taxableKobo = subtotalKobo - discountKobo;
  const vatKobo = Math.round((taxableKobo * vatBps) / 10000);
  return {
    subtotalKobo,
    discountKobo,
    taxableKobo,
    vatKobo,
    totalKobo: taxableKobo + vatKobo,
  };
}

const basket = [
  { name: 'Gari, 5kg', unitKobo: 450000, qty: 2 },
  { name: 'Palm oil, 1L', unitKobo: 285000, qty: 1 },
  { name: 'Sachet water, bag', unitKobo: 35000, qty: 3 },
];

const plain = totalBasket(basket, { discountBps: 0 });
const discounted = totalBasket(basket, { discountBps: 1000 });     // 10% off
const everyFieldIsAnInteger = Object.values(discounted).every(Number.isInteger);

const freeOfCharge = totalBasket(basket, { discountBps: 10000 });  // 100% off
const emptyBasket = totalBasket([], { discountBps: 1000 });

// A half-kobo really does turn up: 7.5% of 1999 kobo is 149.925.
const oneOddItem = totalBasket([{ name: 'Sachet, single', unitKobo: 1999, qty: 1 }]);
const unrounded = (1999 * VAT_BPS) / 10000;

console.log('subtotal :', naira(plain.subtotalKobo));
console.log('10% off  :', naira(discounted.discountKobo), '| VAT', naira(discounted.vatKobo));
console.log('to pay   :', naira(discounted.totalKobo));
console.log('one item :', oneOddItem.vatKobo, 'kobo of VAT on', unrounded, 'exactly');

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 subtotal is a whole number of kobo', [plain.subtotalKobo, naira(plain.subtotalKobo)], [1290000, '\u20a612,900.00']);
test('VAT on the undiscounted basket', [plain.vatKobo, plain.totalKobo], [96750, 1386750]);
test('10% off comes before VAT, so the tax base shrinks too', [discounted.discountKobo, discounted.taxableKobo, discounted.vatKobo], [129000, 1161000, 87075]);
test('and the total prints with separators and exactly two decimals', naira(discounted.totalKobo), '\u20a612,480.75');
test('every field stayed an integer, which is the whole point of working in kobo', everyFieldIsAnInteger, true);
test('a half-kobo is rounded once, at the end, rather than carried', [unrounded, oneOddItem.vatKobo, oneOddItem.totalKobo], [149.925, 150, 2149]);
test('100% off leaves nothing to tax and nothing to pay', [freeOfCharge.taxableKobo, freeOfCharge.vatKobo, freeOfCharge.totalKobo], [0, 0, 0]);
test('an empty basket totals to zero rather than NaN', [emptyBasket.subtotalKobo, naira(emptyBasket.totalKobo)], [0, '\u20a60.00']);
test('and the reason for all of it: naira as a float cannot even add up', 0.1 + 0.2 === 0.3, false);

Money in floats is not a style preference. 0.1 + 0.2 is not 0.3, and the error compounds across a basket, so a subtotal computed in naira and a total computed from the same lines can disagree by a kobo — which is a reconciliation failure, not a rounding nicety. Integers in the smallest unit remove the entire class of bug: every amount here is exact, and the fifth assertion is the proof.

Basis points do the same job for rates. × 750 / 10000 keeps the multiplication in integers until the single division, where × 0.075 is already inexact before the multiplication starts. Storing the rate as an integer also makes it safe to put in a database column and compare for equality.

Rounding once, at the end, and in a defined direction, is the third rule. The sixth assertion is the case: 7.5% of 1999 kobo is 149.925, and something has to decide. Rounding each line and then summing gives a different answer from summing and then rounding, and tax authorities care which you did. Discount before VAT is likewise a rule, not a choice — tax is charged on what the customer actually pays. Printing is the last step and belongs nowhere near the arithmetic. toFixed(2) for the decimals, one regex for the grouping. Intl.NumberFormat with currency: 'NGN' is the fuller answer for a real UI, and it is deliberately avoided here because its exact output — symbol, spacing, minus sign placement — depends on the platform's locale data, which is not something a test should assert.

95 A receipt padded into columns that actually line up

fair

Render that basket as a 40-character receipt: description on the left, quantity and amount right-aligned in fixed columns, a rule, and a total row. Every line must be exactly 40 characters. Then find the two ways this breaks — a description longer than its column, and an amount wider than its column — and a third that only shows up with an accented name.

Stuck? Three hints, smallest first
  1. 1. padEnd for the left column, padStart for the right ones. The argument is the finished width.
  2. 2. Padding never shortens anything, so a long value has to be cut deliberately: pad, then slice.
  3. 3. padEnd counts UTF-16 code units, not the characters you can see.

The answer

const WIDTH = 40;
const NAME_COL = 22;
const QTY_COL = 3;
const CASH_COL = 15;

const money = (kobo) => (kobo / 100).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const fit = (text, width) => (text.length <= width ? text.padEnd(width) : text.slice(0, width - 1) + '\u2026');
const row = (name, qty, kobo) => fit(name, NAME_COL) + String(qty).padStart(QTY_COL) + money(kobo).padStart(CASH_COL);
const rule = (char) => char.repeat(WIDTH);
const totalRow = (label, kobo) => fit(label, NAME_COL + QTY_COL) + money(kobo).padStart(CASH_COL);

const basket = [
  { name: 'Gari, 5kg', unitKobo: 450000, qty: 2 },
  { name: 'Palm oil, 1L', unitKobo: 285000, qty: 1 },
  { name: 'Sachet water (bag of 20 sachets)', unitKobo: 35000, qty: 3 },
];

const lines = basket.map((line) => row(line.name, line.qty, line.unitKobo * line.qty));
const subtotalKobo = basket.reduce((sum, line) => sum + line.unitKobo * line.qty, 0);
const vatKobo = Math.round((subtotalKobo * 750) / 10000);

const receipt = [
  fit('ADAURA STORES, IKEJA', WIDTH),
  rule('='),
  ...lines,
  rule('-'),
  totalRow('Subtotal', subtotalKobo),
  totalRow('VAT 7.5%', vatKobo),
  totalRow('TOTAL', subtotalKobo + vatKobo),
];

const everyLineIsExactlyWide = receipt.map((line) => line.length);
const longNameFitted = fit('Sachet water (bag of 20 sachets)', NAME_COL);
const decimalPointColumns = lines.map((line) => line.indexOf('.'));

// An amount too wide for its column pushes the row out instead of being cut.
const oversized = row('Generator, 5kVA', 1, 100000000000);

// padEnd counts code units, so a decomposed accent eats a column of padding.
const decomposed = 'Ad\u00e9yemi'.normalize('NFD');
const padded = decomposed.padEnd(10);
const composedPadded = decomposed.normalize('NFC').padEnd(10);

console.log(receipt.join('\n'));
console.log('oversized row length:', oversized.length, '<- pushed past 40');
console.log('padded NFD          :', padded.length, 'code units,', padded.replace(/\p{M}/gu, '').length, 'visible');

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('all nine lines of the receipt are exactly 40 characters', everyLineIsExactlyWide, [40, 40, 40, 40, 40, 40, 40, 40, 40]);
test('a description too long for its column is cut and ellipsised, to exactly the column width', [longNameFitted, longNameFitted.length], ['Sachet water (bag of \u2026', 22]);
test('so the decimal points land in the same column on every line', decimalPointColumns, [37, 37, 37]);
test('the total rows use the description and quantity columns together', totalRow('TOTAL', 1386750).length, 40);
test('padStart never truncates, so an amount wider than its column widens the row', [oversized.length, oversized.endsWith('1,000,000,000.00')], [41, true]);
test('padding counts code units: a decomposed accent leaves the column a character short', [padded.length, padded.replace(/\p{M}/gu, '').length], [10, 9]);
test('composing it first is the fix', [composedPadded.length, composedPadded.replace(/\p{M}/gu, '').length], [10, 10]);
test('and the two forms look identical while comparing as different strings', [decomposed === decomposed.normalize('NFC'), decomposed.normalize('NFC').length], [false, 7]);

The rule to internalise is that padStart and padEnd only ever add. Given a value already wider than the target they return it untouched, silently, which is how one long product name or one unexpectedly large amount destroys the alignment of a whole receipt. Fixed-width output therefore needs both operations: pad to the width, and slice to the width. The fifth assertion is that failure left in on purpose — a row 41 characters wide, because nothing cut the amount.

Cutting to width - 1 and appending an ellipsis keeps the column honest, and the third assertion is what the whole exercise is for: the decimal points all land in the same column, so the numbers can be read down the page. That is the actual product requirement, and it holds only because every line is built from the same three widths rather than from spaces typed by hand.

The last three assertions are the part that catches people out on real customer names. 'Adéyemi' can be seven code points with a precomposed é, or eight with a combining acute — the two render identically, compare as different strings, and pad to different visible widths, because padEnd counts code units and the combining mark is a unit that occupies no column. Normalising to NFC on the way in fixes the padding and the comparison at once. It still does not fix emoji or scripts with wide glyphs: a genuinely wide character occupies two terminal columns whatever length says, which is why monospaced output of arbitrary user text is harder than it looks.

96 A CSV writer and reader that round-trip awkward fields

hard

Write rows to CSV, quoting only the fields that need it — a comma, a quote, a line break, or leading and trailing spaces — and doubling any quote inside. Then parse it back with a character-by-character state machine and prove the round trip. Show what split(',') makes of a quoted field, and what a trailing newline does to the row count.

Stuck? Three hints, smallest first
  1. 1. Quote when the field contains ,, ", CR or LF, or when trimming it would change it. Escape a quote by doubling it.
  2. 2. A parser cannot work line by line, because a quoted field is allowed to contain a line break.
  3. 3. Two states are enough: inside quotes and outside. Inside, only a quote is special.

The answer

const needsQuoting = (field) => /[",\r\n]/.test(field) || field !== field.trim();

const writeField = (value) => {
  const text = value === null || value === undefined ? '' : String(value);
  return needsQuoting(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
};

const toCsv = (rows) => rows.map((row) => row.map(writeField).join(',')).join('\r\n');

function fromCsv(text) {
  const rows = [];
  let row = [];
  let field = '';
  let inQuotes = false;
  let i = 0;
  while (i < text.length) {
    const ch = text[i];
    if (inQuotes) {
      if (ch === '"' && text[i + 1] === '"') { field += '"'; i += 2; continue; }   // an escaped quote
      if (ch === '"') { inQuotes = false; i += 1; continue; }
      field += ch; i += 1; continue;
    }
    if (ch === '"') { inQuotes = true; i += 1; continue; }
    if (ch === ',') { row.push(field); field = ''; i += 1; continue; }
    if (ch === '\r' || ch === '\n') {
      row.push(field);
      rows.push(row);
      row = [];
      field = '';
      i += ch === '\r' && text[i + 1] === '\n' ? 2 : 1;
      continue;
    }
    field += ch; i += 1;
  }
  row.push(field);
  rows.push(row);
  return rows;
}

const rows = [
  ['name', 'city', 'note', 'kobo'],
  ['Adaeze Fabrics', 'Lagos', 'Ships Mon, Wed and Fri', '1290000'],
  ['Chidi "Chief" Okoro', 'Abuja', 'Said: "call before 9am"', '450000'],
  ['Ngozi Stores', 'Ibadan', 'Line one\nLine two', ''],
  ['  padded  ', 'Kano', '', '7500'],
];

const csv = toCsv(rows);
const csvLines = csv.split('\r\n');
const backAgain = fromCsv(csv);
const roundTripped = JSON.stringify(backAgain) === JSON.stringify(rows);

const naiveSplit = csvLines[1].split(',').length;
const properly = backAgain[1].length;

const withTrailingBreak = fromCsv('name,city\r\nAdaeze,Lagos\r\n');

console.log(csv);
console.log('round trip :', roundTripped);
console.log('split(",") :', naiveSplit, 'fields where there are', properly);
console.log('trailing \\r\\n gives', withTrailingBreak.length, 'rows:', withTrailingBreak);

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('only the field with a comma in it gets quoted', csvLines[1], 'Adaeze Fabrics,Lagos,"Ships Mon, Wed and Fri",1290000');
test('an internal quote is doubled and the whole field wrapped', csvLines[2], '"Chidi ""Chief"" Okoro",Abuja,"Said: ""call before 9am""",450000');
test('a field with a line break is quoted, so the file has more lines than rows', [csv.split('\n').length, rows.length], [6, 5]);
test('padding survives because trimming would have changed the field', csvLines[4], '"  padded  ",Kano,,7500');
test('and every row comes back exactly as it went in', roundTripped, true);
test('the empty fields read back as empty strings, not undefined', [backAgain[3][3], backAgain[4][2]], ['', '']);
test('splitting that line on a comma finds five fields where there are four', [naiveSplit, properly], [5, 4]);
test('a trailing line break leaves a phantom final row of one empty field', [withTrailingBreak.length, withTrailingBreak[2]], [3, ['']]);

CSV is only simple until a field contains a comma, and by then the naive writer has already produced a file that reads back with the wrong number of columns. The rule from RFC 4180 is short: quote when you must, and double a quote inside. The one addition here is quoting fields with leading or trailing spaces, because most readers strip them and a padded value would otherwise not survive the trip.

The third assertion is why a parser cannot be written as text.split('\n').map(line => line.split(',')). A quoted field may contain a line break, so the file has six lines for five rows, and any line-oriented reader tears that row in half. Scanning character by character with one boolean of state is barely more code and is actually correct.

The last assertion is the detail that shows up as an off-by-one in production: a file ending in a newline parses to one more row than it has records, and that phantom row is [''], not [], because a field always exists between delimiters. Skip a final row that is a single empty field rather than trusting the count. Note also what a round trip does not preserve — types. Everything comes back a string, including '' where a number used to be, which is why the amounts here are strings all along.

97 A slug generator, and what to do when it comes out empty

easy

Turn a title into a URL slug: fold accents away, lower-case it, collapse every run of punctuation into a single hyphen, and trim the hyphens off both ends. Cap the length without cutting a word in half. Then make it unique against slugs already taken, and be honest about the inputs that produce nothing at all.

Stuck? Three hints, smallest first
  1. 1. normalize('NFD') splits an accented letter into a letter and a mark; deleting \p{Diacritic} leaves the letter.
  2. 2. One replace(/[^a-z0-9]+/g, '-') handles spaces, punctuation and runs of both in a single pass.
  3. 3. To cap without cutting a word, slice one character past the limit and cut back to the last hyphen.

The answer

function slugify(text, max = 60) {
  const base = text
    .normalize('NFD')
    .replace(/\p{Diacritic}/gu, '')       // fold the accents off
    .toLowerCase()
    .replace(/['\u2019]/g, '')            // an apostrophe disappears rather than becoming a hyphen
    .replace(/[^a-z0-9]+/g, '-')          // everything else collapses to one hyphen
    .replace(/^-+|-+$/g, '');
  if (base.length <= max) return base;
  const oneOver = base.slice(0, max + 1);           // one character past, to see if we cut a word
  const lastHyphen = oneOver.lastIndexOf('-');
  const cut = lastHyphen > 0 ? oneOver.slice(0, lastHyphen) : oneOver.slice(0, max);
  return cut.replace(/-+$/, '');
}

function uniqueSlug(base, taken) {
  if (base === '') return '';
  if (!taken.has(base)) return base;
  let n = 2;
  while (taken.has(base + '-' + n)) n += 1;
  return base + '-' + n;
}

const plain = slugify('Lagos Island Market');
const accented = slugify('Ọlámidé Adéyemi');
const punctuated = slugify('Lagos --- Ibadan!!! Express');
const edges = slugify('  ¡Hola, Naija!  ');
const apostrophe = slugify("Chief's Bar & Grill");

const longTitle = 'Ikeja City Mall, Alausa, Ikeja, Lagos State, Nigeria';
const cappedAt40 = slugify(longTitle, 40);
const cappedAt30 = slugify(longTitle, 30);

const punctuationOnly = slugify('!!! ---');
const otherScript = slugify('文章');

const taken = new Set(['lagos-island-market', 'lagos-island-market-2']);
const firstFree = uniqueSlug(plain, taken);
const noneTaken = uniqueSlug('yaba-market', taken);

console.log('accented   :', accented);
console.log('punctuated :', punctuated, '|', edges, '|', apostrophe);
console.log('capped     :', cappedAt40, '(' + cappedAt40.length + ')');
console.log('unique     :', firstFree);

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('spaces become single hyphens and everything is lower case', plain, 'lagos-island-market');
test('accents and the Yoruba dot below are folded to plain letters', accented, 'olamide-adeyemi');
test('a run of punctuation collapses to one hyphen, never two', punctuated, 'lagos-ibadan-express');
test('leading and trailing junk is trimmed off both ends', edges, 'hola-naija');
test('an apostrophe is deleted rather than becoming a hyphen', apostrophe, 'chiefs-bar-grill');
test('the cap cuts back to a word boundary and never leaves a trailing hyphen', [cappedAt40, cappedAt40.length, cappedAt30], ['ikeja-city-mall-alausa-ikeja-lagos-state', 40, 'ikeja-city-mall-alausa-ikeja']);
test('punctuation alone, or a script with no Latin letters, slugs to nothing at all', [punctuationOnly, otherScript], ['', '']);
test('and a taken slug gets the first free suffix, while a free one is left alone', [firstFree, noneTaken], ['lagos-island-market-3', 'yaba-market']);

normalize('NFD') then deleting \p{Diacritic} is the whole accent-folding trick: NFD splits é into e plus a combining acute, and the mark is what gets deleted. It handles the Yoruba ọ too, because the dot below is a combining mark like any other. What it does not handle is a letter that has no decomposition — ø, ß, đ, ł stay exactly as they are and then get replaced by a hyphen, so a name in one of those alphabets needs an explicit lookup table. Exercise 98 has the collation version of this problem.

Order matters in that chain. Folding before lower-casing, and collapsing runs with + rather than one character at a time, is what stops lagos---ibadan becoming lagos-----ibadan. Trimming the ends last is required because the collapse itself creates the leading and trailing hyphens.

The seventh assertion is the case that reaches production as a broken URL. A title made entirely of punctuation, or written in a script with no Latin letters at all, slugs to the empty string — and /vendors/ is not a page. Anything generating slugs from user input needs a fallback: the record id, a transliteration library, or a rejection at the form. And the eighth assertion is why the suffix search starts at 2 and walks up: the first collision is -2, not -1, and -2 may itself be taken.

Newsletter

New writing, when it lands.

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