Emmanuel Akinfulubi

JavaScript: Fundamental (ES6), part 1, 77–106

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

Exercises 77 to 106 of 150

77 Computed keys, shorthand, and the __proto__ that is not a key

fair

Build an object whose keys come from variables and expressions, add a shorthand property and a shorthand method, and hide one entry behind a symbol. Then show two traps: an arrow written as a method has no this to use, and __proto__ written plainly in a literal sets the prototype instead of becoming a key.

Stuck? Three hints, smallest first
  1. 1. Anything in [ ] is evaluated and coerced to a string key, unless it is a symbol.
  2. 2. A shorthand method gets its own this; an arrow in the same position takes this from the surrounding scope, which in a strict module is undefined.
  3. 3. { __proto__: x } is a special form in an object literal. { ['__proto__']: x } is not.

The answer

const field = 'lga';
const zone = 'B';
const idKey = Symbol('id');

const stall = {
  [field]: 'Ikeja',
  [`zone${zone}`]: true,
  zone,
  [1 + 1]: 'row two',
  [idKey]: 4417,
  describe() {
    return `${this[field]} zone ${this.zone}`;
  },
  arrowDescribe: () => `${this === undefined ? 'no this' : 'has this'}`,
};

const keys = Object.keys(stall);
const described = stall.describe();
const arrowSaw = stall.arrowDescribe();

const brokenArrow = { name: 'Yaba', label: () => `stall ${this?.name}` };
const brokenSaid = brokenArrow.label();

const symbolIsHidden = [keys.includes('id'), JSON.stringify(stall).includes('4417')];
const symbolIsReachable = [stall[idKey], Object.getOwnPropertySymbols(stall).length];

const last = 'takings';
const duplicated = { [last]: 1, [last]: 2, takings: 3 };

const special = { __proto__: null, good: 'garri' };
const computed = { ['__proto__']: null, good: 'garri' };
const prototypes = [Object.getPrototypeOf(special), Object.getPrototypeOf(computed) === Object.prototype];
const computedOwnsIt = Object.keys(computed);

console.log('keys   :', keys, '| numeric key:', stall[2]);
console.log('methods:', described, '| arrow:', arrowSaw, brokenSaid);
console.log('symbol :', symbolIsHidden, symbolIsReachable);
console.log('proto  :', prototypes, computedOwnsIt);

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 computed key can come from a variable or from any expression', keys, ['2', 'lga', 'zoneB', 'zone', 'describe', 'arrowDescribe']);
test('a number in brackets becomes a string key, and sorts first', [stall[2], stall['2']], ['row two', 'row two']);
test('shorthand copies the variable name, and a shorthand method has its own this', [stall.zone, described], ['B', 'Ikeja zone B']);
test('an arrow in the same slot sees the enclosing this, which here is undefined', [arrowSaw, brokenSaid], ['no this', 'stall undefined']);
test('a symbol key is invisible to keys and to JSON', symbolIsHidden, [false, false]);
test('but it is a perfectly ordinary own property if you hold the symbol', symbolIsReachable, [4417, 1]);
test('a repeated key is not an error: the last one written wins', duplicated.takings, 3);
test('plain __proto__ in a literal sets the prototype instead of adding a key', prototypes, [null, true]);
test('computed __proto__ is just a key, so the object keeps its prototype', computedOwnsIt, ['__proto__', 'good']);

Computed keys turn an object literal into something you can build from data, and they are evaluated in order, so a later duplicate quietly overwrites an earlier one. Anything that is not a symbol is coerced with String(), which is why [1 + 1] becomes "2" and lands at the front of the key order along with the other integer-like keys.

Shorthand methods and arrow properties look interchangeable and are not. A method gets this from the call, so stall.describe() sees the stall. An arrow captures this from where it was written, which inside a module or a panel running in strict mode is undefined — so this?.name is the only thing keeping the second example from throwing. Arrows are the right choice for a callback that must remember its surroundings, and the wrong choice for a method on the object you are defining.

__proto__ in a literal is a genuine wart. Written plainly it is a directive to set the prototype, so { __proto__: null } is the shortest way to get a bag with no inheritance; written as a computed key or a quoted-then-computed key it is an ordinary property. That difference is the root of a whole family of prototype-pollution bugs, and it is the reason parsers and merge helpers treat the name as reserved.

78 Accessors in an object literal, and the getter you cannot write to

fair

Give a wallet object a naira accessor over its kobo field: the getter divides, the setter validates and rounds. Add a computed accessor name and a getter-only property, then show what strict mode does when you assign to a getter with no setter, and what a spread copy turns your accessor into.

Stuck? Three hints, smallest first
  1. 1. A getter and a setter of the same name are two halves of one property, and reading it looks exactly like reading a field.
  2. 2. Assigning to a property that only has a getter is a silent no-op in sloppy mode and a TypeError in strict mode.
  3. 3. Spread reads values, so it calls the getter once and stores the result as an ordinary data property.

The answer

const unit = 'naira';

const wallet = {
  kobo: 500000,
  get [unit]() {
    return this.kobo / 100;
  },
  set [unit](value) {
    if (typeof value !== 'number' || Number.isNaN(value)) throw new TypeError('naira must be a number');
    this.kobo = Math.round(value * 100);
  },
  get label() {
    return `₦${this.naira.toFixed(2)}`;
  },
};

const readAsNumber = [wallet.naira, typeof wallet.naira];
wallet.naira = 62.505;
const afterWrite = [wallet.kobo, wallet.label];

let rejected;
try {
  wallet.naira = 'plenty';
} catch (error) {
  rejected = error.constructor.name;
}
const unchanged = wallet.kobo;

let readOnly;
try {
  wallet.label = 'free';
} catch (error) {
  readOnly = error.constructor.name;
}

const descriptor = Object.getOwnPropertyDescriptor(wallet, 'naira');
const descriptorShape = [typeof descriptor.get, typeof descriptor.set, 'value' in descriptor, descriptor.enumerable];
const keysIncludeAccessors = Object.keys(wallet);
const serialised = JSON.parse(JSON.stringify(wallet));

const copy = { ...wallet };
const copyDescriptor = Object.getOwnPropertyDescriptor(copy, 'naira');
copy.kobo = 1;
const copyWentStale = [copy.naira, typeof copyDescriptor.get, copyDescriptor.value];

console.log('read   :', readAsNumber, '| after write:', afterWrite);
console.log('reject :', rejected, unchanged, '| read-only:', readOnly);
console.log('shape  :', descriptorShape, keysIncludeAccessors);
console.log('copy   :', copy, copyWentStale);

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 getter reads like a field, not like a method', readAsNumber, [5000, 'number']);
test('the setter is where the conversion and the rounding live', afterWrite, [6251, '₦62.51']);
test('so validation in a setter guards every future write', [rejected, unchanged], ['TypeError', 6251]);
test('and in strict mode assigning to a getter-only property throws', readOnly, 'TypeError');
test('the descriptor holds get and set, and no value at all', descriptorShape, ['function', 'function', false, true]);
test('accessors are enumerable own properties, so keys lists them', keysIncludeAccessors, ['kobo', 'naira', 'label']);
test('and JSON.stringify serialises what the getter returns', serialised, { kobo: 6251, naira: 62.51, label: '₦62.51' });
test('a spread copy freezes the accessor into a plain value', copyWentStale, [62.51, 'undefined', 62.51]);

An accessor lets a computed value be read like a stored one, which is exactly right for a derived quantity: kobo is the truth, naira is a view of it, and no caller has to remember to divide. The setter is the other half of the deal and the more valuable one, because it is the single place a value can be validated, rounded or normalised before it is stored.

Two strict-mode facts. Assigning to a property with only a getter throws a TypeError here; in sloppy mode the same line does nothing and reports nothing, which is one of the quieter bugs in old code. And the descriptor makes the distinction plain: an accessor has get and set, a data property has value and writable, and no property has both.

The spread result is the one to remember. { ...wallet } invokes the getter and stores the number it returned, so the copy has a frozen snapshot with no live link to kobo: change copy.kobo and copy.naira keeps its old answer. If you need to carry an accessor across, Object.defineProperties with Object.getOwnPropertyDescriptors is the copy that preserves it.

79 A wallet class with # fields, a static count and a brand check

fair

Write a wallet class whose balance lives in a private #kobo field, with a private method that validates, a static counter that tracks how many were made, a static factory, and a brand check written as #kobo in value. Then show that the private field is genuinely unreachable from outside and that a detached method loses its this.

Stuck? Three hints, smallest first
  1. 1. A # name is not a string key: it is not in Object.keys, not in JSON.stringify, and not reachable with brackets.
  2. 2. static #count belongs to the class, and a static method is the only thing that can read it.
  3. 3. #kobo in value is a true brand check: it answers "was this object built by this class" without throwing.

The answer

class Wallet {
  #kobo = 0;
  static #opened = 0;

  constructor(kobo = 0) {
    this.#kobo = Wallet.#check(kobo);
    Wallet.#opened++;
  }

  static #check(kobo) {
    if (!Number.isInteger(kobo) || kobo < 0) throw new RangeError('kobo must be a whole number of at least zero');
    return kobo;
  }

  static fromNaira(naira) {
    return new Wallet(Math.round(naira * 100));
  }

  static get opened() {
    return Wallet.#opened;
  }

  static isWallet(value) {
    return #kobo in value;
  }

  get naira() {
    return this.#kobo / 100;
  }

  credit(kobo) {
    this.#kobo += Wallet.#check(kobo);
    return this;
  }

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

const a = new Wallet(150000);
const b = Wallet.fromNaira(62.505);
a.credit(50000).credit(1);

const balances = [a.toJSON(), b.toJSON()];
const openedCount = Wallet.opened;
const hiddenFromReflection = [Object.keys(a), JSON.stringify(b), 'kobo' in a];

const brands = [Wallet.isWallet(a), Wallet.isWallet({ kobo: 1 }), Wallet.isWallet(Wallet.prototype)];

let refused;
try {
  new Wallet(-5);
} catch (error) {
  refused = error.constructor.name;
}

let outside;
try {
  outside = new Function('return (w) => w.#kobo;');
} catch (error) {
  outside = error.constructor.name;
}

const detached = a.credit;
let lostThis;
try {
  detached(1);
} catch (error) {
  lostThis = error.constructor.name;
}

console.log('balances:', balances, '| opened:', openedCount);
console.log('hidden  :', hiddenFromReflection);
console.log('brands  :', brands, '| refused:', refused);
console.log('outside :', outside, '| detached:', lostThis);

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 private field holds the truth and a getter exposes a view of it', balances, [{ kobo: 200001, naira: 2000.01 }, { kobo: 6251, naira: 62.51 }]);
test('a static private counter survives across every instance', openedCount, 2);
test('nothing about #kobo shows up in keys, in JSON, or to the in operator', hiddenFromReflection, [[], '{"kobo":6251,"naira":62.51}', false]);
test('so toJSON is how a class with private state gets serialised at all', JSON.parse(JSON.stringify(a)), { kobo: 200001, naira: 2000.01 });
test('the brand check is true for instances and false for a look-alike', brands, [true, false, false]);
test('a private static validator guards every entry point, constructor included', refused, 'RangeError');
test('reading a # field from outside the class body is a syntax error, not a runtime one', outside, 'SyntaxError');
test('and a detached method has no this in strict mode, so the private read throws', lostThis, 'TypeError');

# privacy is real, not a convention. The name is lexically scoped to the class body, so there is no string key to guess, nothing in Object.keys or JSON.stringify, and no way to reach it from outside — the attempt is rejected by the parser, which is why the assertion above catches a SyntaxError rather than a TypeError. The cost is that serialisation becomes your job, and toJSON is the usual answer.

static #opened lives on the class, so it counts across all instances and only static members can read it. Pairing it with a static factory such as fromNaira gives you named constructors without touching the real one, and a private static validator means every path in — constructor, factory, credit — is checked by the same code.

Two sharp edges. #kobo in value is the brand check to use: instanceof can be fooled by a changed prototype, while the in form asks whether this class actually installed its field, and it cannot throw. And because credit reads a private field through this, pulling it off the instance breaks it: const f = a.credit; f(1) gets this === undefined under strict mode and throws. Pass () => a.credit(1) or a bound method when handing a class method to a callback.

80 A frozen tariff, and the write that throws in strict mode

fair

Freeze a tariff object and try four things on it: change a field, delete a field, add a field, and change something one level deeper. Then freeze an array and try to push. Note which of those throw and which quietly succeed, and say what const protects that freeze does not.

Stuck? Three hints, smallest first
  1. 1. In strict mode a write to a frozen property is a TypeError. In sloppy mode the same line does nothing at all.
  2. 2. Freezing is one level deep: the reference is locked, the object it points at is not.
  3. 3. const locks the binding; freeze locks the contents. They protect different things and neither implies the other.

The answer

const tariff = Object.freeze({
  base: 25000,
  perKm: 1500,
  areas: { Lekki: 2500, Ikeja: 1000 },
  zones: ['A', 'B'],
});

const attempt = (fn) => {
  try {
    fn();
    return 'allowed';
  } catch (error) {
    return error.constructor.name;
  }
};

const changed = attempt(() => {
  tariff.base = 1;
});
const removed = attempt(() => {
  delete tariff.perKm;
});
const added = attempt(() => {
  tariff.surge = 2;
});
const nested = attempt(() => {
  tariff.areas.Lekki = 9999;
});
const pushedInside = attempt(() => {
  tariff.zones.push('C');
});

const stillOriginal = [tariff.base, tariff.perKm, 'surge' in tariff];
const nestedMoved = [tariff.areas.Lekki, tariff.zones];

const frozenList = Object.freeze(['garri', 'rice']);
const pushed = attempt(() => {
  frozenList.push('beans');
});
const sortedInPlace = attempt(() => {
  frozenList.sort();
});

const flags = Object.seal({ open: true, staff: 2 });
const sealedWrite = attempt(() => {
  flags.staff = 3;
});
const sealedAdd = attempt(() => {
  flags.closingTime = '18:00';
});
const states = [Object.isFrozen(tariff), Object.isSealed(tariff), Object.isFrozen(flags), Object.isSealed(flags)];

let rebindable = tariff;
rebindable = { base: 0 };
const primitivesPassThrough = [Object.freeze(5), Object.isFrozen(5)];

console.log('writes :', changed, removed, added, '| nested:', nested, pushedInside);
console.log('after  :', stillOriginal, nestedMoved);
console.log('array  :', pushed, sortedInPlace, '| sealed:', sealedWrite, sealedAdd);
console.log('states :', states, '| rebound:', rebindable.base);

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('changing, deleting and adding a property all throw on a frozen object', [changed, removed, added], ['TypeError', 'TypeError', 'TypeError']);
test('so the top level really is unchanged', stillOriginal, [25000, 1500, false]);
test('but one level down nothing is protected', [nested, pushedInside], ['allowed', 'allowed']);
test('and those changes stuck', nestedMoved, [9999, ['A', 'B', 'C']]);
test('a frozen array refuses push, and refuses to sort itself in place', [pushed, sortedInPlace], ['TypeError', 'TypeError']);
test('sealing allows writes to what exists but no new keys', [sealedWrite, sealedAdd, flags.staff], ['allowed', 'TypeError', 3]);
test('every frozen object is sealed; the reverse does not hold', states, [true, true, false, true]);
test('freeze locks the contents, not the name, so a let holding it can be rebound', rebindable.base, 0);
test('and freezing a primitive is a no-op that reports as already frozen', primitivesPassThrough, [5, true]);

Strict mode is what makes Object.freeze useful. Under it, every one of the three top-level attempts above raises a TypeError you can see in a stack trace; without it the assignment evaluates to the value you assigned, changes nothing, and reports nothing — a frozen object that lies about being writable. Everything in this course runs strict, which is why attempt can report an error name at all.

The shallowness is the part that bites. freeze makes each own property non-writable and non-configurable, and that is where it stops: tariff.areas cannot be repointed, but the object it points at is entirely open, and an array field can still be pushed to and sorted. A config object frozen at the top and mutated three levels down is a very common way to be surprised. Recursing over the properties is the fix, with the usual caveats about cycles and about not freezing things you did not create.

Keep const and freeze separate in your head. const says this name will never point at anything else; freeze says this object will never change. Object.seal sits between them: existing properties stay writable, no new ones may appear, and none may be deleted.

81 structuredClone against a spread copy

fair

Copy an order two ways — with a spread and with structuredClone — then change something nested in each copy and see which original moved. Check what the deep copy does with a Map, a Date and a cycle, then find the two things it will not do: keep a class prototype, and carry a function.

Stuck? Three hints, smallest first
  1. 1. A spread copies one level. Nested objects are the same objects, shared between copy and original.
  2. 2. structuredClone walks the whole structure, remembers what it has seen, and so survives a cycle that JSON.stringify cannot.
  3. 3. It copies data, not behaviour: a function makes it throw, and a class instance comes back as a plain object.

The answer

class Buyer {
  constructor(name) {
    this.name = name;
  }
  shout() {
    return this.name.toUpperCase();
  }
}

const order = {
  ref: 'IK-104',
  buyer: new Buyer('Adaeze'),
  address: { lga: 'Ikeja', street: 'Allen' },
  goods: ['garri', 'rice'],
  placed: new Date(0),
  counts: new Map([['garri', 2]]),
  seen: new Set(['garri']),
  note: undefined,
};

const shallow = { ...order };
const deep = structuredClone(order);

shallow.address.street = 'Awolowo';
const originalMovedWithShallow = order.address.street;

deep.address.street = 'Toyin';
const originalIgnoredDeep = order.address.street;
const sharedReferences = [shallow.address === order.address, deep.address === order.address];

deep.goods.push('beans');
const goodsStayed = order.goods.length;

const dateSurvived = [deep.placed instanceof Date, deep.placed.getTime()];
const collectionsSurvived = [deep.counts instanceof Map, [...deep.counts], deep.seen.has('garri')];
const undefinedSurvived = ['note' in deep, deep.note === undefined];

const cyclic = { lga: 'Ikeja' };
cyclic.itself = cyclic;
const clonedCycle = structuredClone(cyclic);
const cycleHeld = [clonedCycle.itself === clonedCycle, clonedCycle !== cyclic];
let jsonOnCycle;
try {
  JSON.stringify(cyclic);
} catch (error) {
  jsonOnCycle = error.constructor.name;
}

const prototypeLost = [order.buyer.shout(), deep.buyer instanceof Buyer, deep.buyer.name, typeof deep.buyer.shout];
let withFunction;
try {
  structuredClone({ price: () => 1 });
} catch (error) {
  withFunction = error.name;
}

const throughJson = JSON.parse(JSON.stringify({ placed: new Date(0), note: undefined, counts: new Map([['a', 1]]) }));
const jsonFlattens = [typeof throughJson.placed, 'note' in throughJson, throughJson.counts];

console.log('shallow:', originalMovedWithShallow, '| deep:', originalIgnoredDeep);
console.log('shared :', sharedReferences, '| goods:', goodsStayed);
console.log('kept   :', dateSurvived, collectionsSurvived, undefinedSurvived);
console.log('lost   :', prototypeLost, withFunction, '| json:', jsonFlattens);

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 nested write through a spread copy is a write to the original', [originalMovedWithShallow, sharedReferences], ['Awolowo', [true, false]]);
test('the deep copy is genuinely separate, arrays included', [originalIgnoredDeep, goodsStayed], ['Awolowo', 2]);
test('a Date comes back as a Date, not as a string', dateSurvived, [true, 0]);
test('and a Map and a Set come back as themselves', collectionsSurvived, [true, [['garri', 2]], true]);
test('an explicit undefined property is kept, key and all', undefinedSurvived, [true, true]);
test('a cycle is fine, where JSON.stringify gives up', [cycleHeld, jsonOnCycle], [[true, true], 'TypeError']);
test('but the prototype is dropped: the data survives, the methods do not', prototypeLost, ['ADAEZE', false, 'Adaeze', 'undefined']);
test('and a function anywhere in the structure makes it throw', withFunction, 'DataCloneError');
test('a JSON round trip loses more still: a Date becomes text, undefined vanishes, a Map empties', jsonFlattens, ['string', false, {}]);

Three copies, three different meanings. A spread or Object.assign duplicates the top level and shares everything below it, which is exactly right for an immutable update where you rebuild only the branch you touched, and exactly wrong when you meant "give me my own copy". structuredClone walks the whole graph and remembers what it has already copied, so cycles and shared references survive as the same shape rather than being duplicated or hanging.

What it cannot do follows from what it is: a data-only serialiser. Prototypes are dropped, so a class instance comes back as a plain object with the right fields and no methods; functions, DOM nodes and symbols make it throw a DataCloneError; getters are read and stored as flat values. Anything with behaviour needs a real clone() or a factory that rebuilds the instance.

Against the old JSON.parse(JSON.stringify(x)) trick it is not close. That one turns a Date into a string, empties a Map and a Set into {}, drops undefined, functions and symbols, mangles NaN and Infinity into null, and throws on a cycle. It is still the fastest way to snapshot plain JSON-shaped data, and it is the wrong tool for anything else.

82 A WeakMap holding data the object cannot see

hard

Keep an account's balance in a WeakMap keyed by the instance instead of on the instance. Show that nothing about the balance appears on the object, that the map cannot be listed or counted, that a primitive key is refused, and that a look-alike object handed to a method is caught rather than served.

Stuck? Three hints, smallest first
  1. 1. The instance is the key: store.set(this, { kobo }) in the constructor, store.get(this) in every method.
  2. 2. A WeakMap has get, set, has and delete and nothing else — no size, no iteration, no keys.
  3. 3. Keys must be objects or symbols, which is what makes the "weak" part possible.

The answer

const store = new WeakMap();
const registered = new WeakSet();

class Account {
  constructor(holder, kobo) {
    this.holder = holder;
    store.set(this, { kobo });
    registered.add(this);
  }

  get kobo() {
    return store.get(this).kobo;
  }

  credit(kobo) {
    const state = store.get(this);
    if (!state) throw new TypeError('not an account this ledger knows');
    state.kobo += kobo;
    return this;
  }
}

const one = new Account('Adaeze', 150000);
const two = new Account('Bola', 20000);
one.credit(50000);

const balances = [one.kobo, two.kobo];
const nothingOnTheObject = [Object.keys(one), JSON.stringify(one), Object.getOwnPropertyNames(one)];
const noApi = [typeof store.size, typeof store[Symbol.iterator], typeof store.keys, typeof store.forEach];

let primitiveKey;
try {
  store.set('Adaeze', { kobo: 1 });
} catch (error) {
  primitiveKey = error.constructor.name;
}
const symbolKeysAreFine = (() => {
  const tag = Symbol('tag');
  const wm = new WeakMap();
  wm.set(tag, 'held');
  return wm.get(tag);
})();

const impostor = Object.create(Account.prototype);
impostor.holder = 'Nobody';
let refused;
try {
  impostor.credit(1);
} catch (error) {
  refused = error.constructor.name;
}
const membership = [registered.has(one), registered.has(impostor), store.has(one)];

const forgotten = new Account('Chidi', 100);
const removed = store.delete(forgotten);
const afterDelete = [store.has(forgotten), store.get(forgotten) === undefined];

console.log('balances:', balances);
console.log('object  :', nothingOnTheObject);
console.log('api     :', noApi, '| primitive key:', primitiveKey);
console.log('brand   :', membership, '| refused:', refused, '| deleted:', removed, afterDelete);

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 balance is readable through the instance but not stored on it', balances, [200000, 20000]);
test('nothing about it shows in keys, in JSON, or in own property names', nothingOnTheObject, [['holder'], '{"holder":"Adaeze"}', ['holder']]);
test('a WeakMap has no size, no iteration and no way to list its keys', noApi, ['undefined', 'undefined', 'undefined', 'undefined']);
test('a string cannot be a key, because there is nothing to hold weakly', primitiveKey, 'TypeError');
test('a symbol can, which is the one non-object key allowed', symbolKeysAreFine, 'held');
test('an object made from the prototype was never registered, so the method refuses it', refused, 'TypeError');
test('a WeakSet answers the same question as a yes-or-no brand', membership, [true, false, true]);
test('and delete removes an entry outright', [removed, afterDelete], [true, [false, true]]);

Keying a map by the instance inverts where private data lives: the object holds nothing, the map holds everything, and the only way in is a reference to the object itself. That gives you privacy that survives reflection — no key in Object.keys, nothing in JSON.stringify, nothing in getOwnPropertyNames — which is how libraries kept state private for years before # fields existed, and how they still do it when the state must be attached to an object they did not create.

The missing API is the price and the point. No size, no keys, no iteration, because any of those would let you observe whether the garbage collector has run. An entry lives exactly as long as its key lives: drop the last reference to an account and the balance becomes unreachable and collectable, with no cleanup code and no leak. A plain Map in the same position holds its keys alive for ever, which is the classic cache-shaped memory leak.

Two practical notes. Keys must be objects or symbols, so a string id cannot be a WeakMap key. And store.get(this) returning undefined is a usable brand check: an object built with Object.create(Account.prototype) has the right prototype and no entry, so the method can refuse it — the same job #field in obj does for a class with real private fields.

83 Symbol.iterator on a class, and who stops the loop

fair

Make a block of bus seats iterable with a generator method named [Symbol.iterator], with a finally that records the close. Then count how many seats are actually produced by a spread, by a two-element destructuring, and by a for...of that breaks early — and check that the object can be iterated again afterwards.

Stuck? Three hints, smallest first
  1. 1. A generator method named [Symbol.iterator] is the whole protocol: each call returns a fresh iterator.
  2. 2. A pattern only pulls what it needs, and then tells the iterator it is finished.
  3. 3. That message is return(), and inside a generator it runs the finally block.

The answer

let produced = 0;

class SeatBlock {
  constructor(rows, perRow) {
    this.rows = rows;
    this.perRow = perRow;
    this.closes = 0;
  }

  *[Symbol.iterator]() {
    try {
      for (let r = 0; r < this.rows; r++) {
        for (let s = 1; s <= this.perRow; s++) {
          produced++;
          yield `${String.fromCharCode(65 + r)}${s}`;
        }
      }
    } finally {
      this.closes++;
    }
  }
}

const block = new SeatBlock(2, 3);

const all = [...block];
const producedBySpread = produced;

produced = 0;
const [firstSeat, secondSeat] = block;
const producedByPattern = produced;

produced = 0;
const found = [];
for (const seat of block) {
  found.push(seat);
  if (seat === 'A2') break;
}
const producedByBreak = produced;

const closesSoFar = block.closes;
const asSet = new Set(block);
const reIterable = [asSet.size, [...block].length];

const bareGenerator = block[Symbol.iterator]();
const firstPull = bareGenerator.next().value;
const drained = [...bareGenerator].length;
const secondTime = [...bareGenerator].length;

const nextButNotIterable = { next: () => ({ value: 'A1', done: false }) };
let spreadFailed;
try {
  spreadFailed = [...nextButNotIterable];
} catch (error) {
  spreadFailed = error.constructor.name;
}

console.log('all    :', all, '| produced:', producedBySpread);
console.log('pattern:', firstSeat, secondSeat, '| produced:', producedByPattern);
console.log('break  :', found, '| produced:', producedByBreak, '| closes:', closesSoFar);
console.log('bare   :', firstPull, drained, secondTime, '| not iterable:', spreadFailed);

Output


      
8 checks that prove this answer

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

8 checks

test('spread drains the whole sequence', [all, producedBySpread], [['A1', 'A2', 'A3', 'B1', 'B2', 'B3'], 6]);
test('a two-element pattern pulls exactly two', [[firstSeat, secondSeat], producedByPattern], [['A1', 'A2'], 2]);
test('and a break stops the generator where it stood', [found, producedByBreak], [['A1', 'A2'], 2]);
test('each early exit ran the finally block, so all three closes were recorded', closesSoFar, 3);
test('being iterable is all a Set constructor asks for', asSet.size, 6);
test('and every fresh call to Symbol.iterator starts again from the top', reIterable, [6, 6]);
test('a generator object, by contrast, is used up once', [firstPull, drained, secondTime], ['A1', 5, 0]);
test('an object with next but no Symbol.iterator is not iterable at all', spreadFailed, 'TypeError');

One generator method named [Symbol.iterator] buys the whole protocol: for...of, spread, array destructuring, Array.from, and the Set, Map and Promise.all constructors all speak it. Writing it by hand — an object with a next that returns { value, done } — works too, but the generator version keeps its position in ordinary control flow, which is far easier to read.

The interesting half is who stops the loop. A consumer that finishes early calls return() on the iterator, and inside a generator that unwinds to the finally, so cleanup happens on break, on an early return, on a throw in the loop body, and after a partial destructuring. That is why a pattern taking two elements from a six-element source produced exactly two and still recorded a close: the pattern pulled twice and then said it was done. Put the file handle, the cursor or the lock release in that finally and it is genuinely safe.

Note the difference between the class and a generator object. The class is iterable: every call to block[Symbol.iterator]() is a new walk, so it can be iterated as often as you like. A generator object is an iterator that also returns itself, so it can be walked once and comes back empty for ever after. If you hand a generator object to two consumers, only the first one sees anything.

84 yield*, and the value a generator returns

fair

Delegate from one generator into another with yield*, and catch the inner generator's return value as the value of the yield* expression. Show that spread never sees that value while next() does, delegate into a plain array and a string, bound an endless generator with a take helper, and stop one early with return().

Stuck? Three hints, smallest first
  1. 1. yield* inner() forwards every yield, and evaluates to whatever inner returned.
  2. 2. for...of and spread stop at done: true and throw the accompanying value away.
  3. 3. Never let for...of loose on an endless generator: take a fixed number and break.

The answer

function* rows(n) {
  for (let i = 1; i <= n; i++) yield `row ${i}`;
  return n;
}

function* block() {
  const counted = yield* rows(2);
  yield `counted ${counted}`;
  return 'block done';
}

const spreadSees = [...block()];
const stepped = [];
const it = block();
let step = it.next();
while (!step.done) {
  stepped.push(step.value);
  step = it.next();
}
const finalStep = [step.value, step.done];

function* mixed() {
  yield 'start';
  yield* ['garri', 'rice'];
  yield* 'NG';
  yield* rows(1);
}
const anyIterable = [...mixed()];

function* doubling(kobo) {
  let current = kobo;
  while (true) {
    yield current;
    current *= 2;
  }
}

const take = (n, iterable) => {
  const out = [];
  for (const value of iterable) {
    out.push(value);
    if (out.length === n) break;
  }
  return out;
};
const firstFive = take(5, doubling(100));

const stoppable = doubling(100);
const beforeStop = [stoppable.next().value, stoppable.next().value];
const stopped = stoppable.return('closed');
const afterStop = stoppable.next();

function* countingDown(from) {
  let n = from;
  while (n > 0) yield n--;
  return 'liftoff';
}
const inner = countingDown(3);
const outerValues = [...(function* () {
  const ending = yield* inner;
  yield ending;
})()];

console.log('spread :', spreadSees);
console.log('stepped:', stepped, '| final:', finalStep);
console.log('mixed  :', anyIterable);
console.log('endless:', firstFive, '| stop:', beforeStop, stopped, afterStop);

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('yield* splices the inner sequence into the outer one', spreadSees, ['row 1', 'row 2', 'counted 2']);
test('so the inner return value arrives as the value of the yield* expression', spreadSees[2], 'counted 2');
test('stepping by hand sees the outer return value; spread never does', [stepped, finalStep], [['row 1', 'row 2', 'counted 2'], ['block done', true]]);
test('yield* works on any iterable, not only on generators', anyIterable, ['start', 'garri', 'rice', 'N', 'G', 'row 1']);
test('an endless generator is safe as long as the consumer stops', firstFive, [100, 200, 400, 800, 1600]);
test('return() ends a generator early and reports the value you gave it', [beforeStop, stopped], [[100, 200], { value: 'closed', done: true }]);
test('after that it is finished, and stays finished', afterStop, { value: undefined, done: true });
test('delegation carries the return value out through as many levels as you like', outerValues, [3, 2, 1, 'liftoff']);

yield* is delegation, not a loop: it forwards every value from the inner iterable, forwards next arguments inwards, forwards throw and return inwards too, and then evaluates to the inner generator's return value. That last part is the only clean way one generator can hand a summary back to its caller — a count, a total, a final cursor — while still having streamed the items one at a time.

Which is exactly what for...of and spread cannot see. They stop when done turns true and discard the value that came with it, so a generator whose answer is in its return looks like it produced nothing extra. If the summary matters, drive the iterator with next() yourself, or yield the summary as a final value like block does above.

Two safety notes. yield* accepts any iterable, so arrays, strings, Maps and other generators all splice in the same way. And an endless generator is only as safe as its consumer: [...doubling(100)] or a bare for...of over it never returns, so bound it with a take that breaks, and use return() when you want the producer to run its own cleanup.

85 A label, and the outer loop a bare break cannot leave

easy

Search a grid of Lagos areas for one name and stop the moment you find it — both loops, not just the inner one. Then use continue on a label to abandon the rest of a row, put a label on a plain block, and show which labelled statements the parser refuses.

Stuck? Three hints, smallest first
  1. 1. A label is a name followed by a colon written in front of a statement: outer: for (...) { ... }.
  2. 2. break outer leaves the statement the label names. continue outer jumps to that loop’s next iteration instead.
  3. 3. A label on a plain { } block works too, but only with break — there is nothing to continue.

The answer

const zones = [
  ['Ikeja', 'Agege', 'Ojota'],
  ['Yaba', 'Surulere', 'Lekki'],
  ['Ajah', 'Ikorodu', 'Epe'],
];

let visits = 0;
const findCell = (grid, wanted) => {
  let found = null;
  outer: for (let r = 0; r < grid.length; r++) {
    for (let c = 0; c < grid[r].length; c++) {
      visits++;
      if (grid[r][c] === wanted) {
        found = [r, c];
        break outer;                  // leaves BOTH loops
      }
    }
  }
  return found;
};
const cell = findCell(zones, 'Surulere');
const cellVisits = visits;
const cellsInGrid = zones.flat().length;

const innerOnly = [];
for (const row of zones) {
  for (const area of row) {
    if (area.startsWith('A')) break;  // only the inner loop; the outer one carries on
    innerOnly.push(area);
  }
}

const clean = [];
rows: for (const row of zones) {
  for (const area of row) {
    if (area === 'Lekki') continue rows;   // give up on this row entirely
  }
  clean.push(row[0]);
}

const viaReturn = (grid, wanted) => {
  for (let r = 0; r < grid.length; r++) {
    for (let c = 0; c < grid[r].length; c++) {
      if (grid[r][c] === wanted) return [r, c];
    }
  }
  return null;
};
const returned = [viaReturn(zones, 'Surulere'), viaReturn(zones, 'Abuja')];

const band = (kobo) => {
  let label = 'none';
  check: {
    if (kobo <= 0) break check;
    label = 'small';
    if (kobo < 100000) break check;
    label = 'large';
  }
  return label;
};
const bands = [band(0), band(50000), band(500000)];

const parse = (src) => {
  try { new Function(src); return 'compiled'; } catch (error) { return error.constructor.name; }
};
const refused = [parse('check: { continue check; }'), parse('outer: for (const x of [1]) { break nope; }')];

console.log('found     :', cell, '| cells visited:', cellVisits, 'of', cellsInGrid);
console.log('bare break:', innerOnly);
console.log('continue  :', clean, '| by return:', returned);
console.log('block      :', bands, '| refused:', refused);

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('break with a label leaves both loops at once', cell, [1, 1]);
test('and it really stops the work, five cells of nine', [cellVisits, cellsInGrid], [5, 9]);
test('a bare break leaves the inner loop only, so the outer one keeps going', innerOnly, ['Ikeja', 'Yaba', 'Surulere', 'Lekki']);
test('continue on a label abandons the rest of that outer iteration', clean, ['Ikeja', 'Ajah']);
test('a small function with return needs no label at all', returned, [[1, 1], null]);
test('a label on a plain block gives a one-way exit out of it', bands, ['none', 'small', 'large']);
test('continue only names a loop, and an unknown label never compiles', refused, ['SyntaxError', 'SyntaxError']);
test('a label is not a binding, so nothing called rows exists', typeof rows, 'undefined');

A label names a statement so break and continue can say which one they mean. Without it they take the nearest enclosing loop, which is why the second block above keeps iterating the outer array after the inner break: it left one loop, not the search. The usual workarounds — a found flag tested in the outer condition, or a thrown exception used as a jump — are both worse than one word in front of the for.

continue label is the less familiar half and the more useful one. It does not leave the labelled loop, it moves it on, which reads as "this row is disqualified, next row" with no nesting of conditions. Put the label on a plain block and you get a forward-only escape, handy for a short validation ladder where each step may finish the job early.

Two limits worth knowing. Labels are not variables: they live in their own namespace, cannot be stored or passed, and are invisible to typeof. And the parser checks them, so continue on a block label or break on a name that is not in scope is a SyntaxError at compile time rather than a runtime failure — which is why both attempts above had to be handed to new Function to be caught at all. When the loop body grows past a screen, extracting it into a function and returning is usually clearer than either.

86 Turning a price string into kobo, and the parsers that lie

fair

Convert a price written the way a customer writes it, ₦1,250.50, into an integer number of kobo. Compare Number, parseInt and parseFloat on the same inputs, and show what each one does with junk. Then take Number.isInteger at its word and find out what it actually checks.

Stuck? Three hints, smallest first
  1. 1. Number wants the whole string and gives NaN if anything is left over. parseInt and parseFloat read a prefix and stop.
  2. 2. Strip everything that is not a digit or a dot, then multiply by 100 and round — but check the stripped string first, because Number('') is 0.
  3. 3. Number.isInteger asks whether the value has no fractional part. It says nothing about the type of the argument, or about the number being safe.

The answer

const naiveKobo = (text) => Math.round(Number(String(text).replace(/[^0-9.]/g, '')) * 100);

const kobo = (text) => {
  const cleaned = String(text).replace(/[^0-9.]/g, '');
  if (cleaned === '') return null;
  const naira = Number(cleaned);
  return Number.isFinite(naira) ? Math.round(naira * 100) : null;
};

const wholeOrNothing = [Number.isNaN(Number('12abc')), parseInt('12abc', 10), parseFloat('3.5kg')];
const exponent = [parseInt('1e3', 10), Number('1e3'), parseFloat('1e3')];
const blanks = [Number(''), Number('   '), naiveKobo('free')];
const guarded = [kobo('₦1,250.50'), kobo('free'), kobo(' 200 ')];
const commaStops = parseFloat('1,250.50');
const tinyNumber = parseInt(0.0000005, 10);

const integers = [Number.isInteger(5.0), Number.isInteger(5e20), Number.isInteger('5'), Number.isSafeInteger(2 ** 53)];
const unsafe = [2 ** 53 === 2 ** 53 + 1, Number.MAX_SAFE_INTEGER];
const nanCheck = [isNaN('abc'), Number.isNaN('abc'), Number.isNaN(Number('abc'))];
const separators = [1_000, Number.isNaN(Number('1_000'))];

console.log('whole     :', wholeOrNothing, '| exponent:', exponent);
console.log('blanks    :', blanks, '| guarded:', guarded);
console.log('comma     :', commaStops, '| 0.0000005 ->', tinyNumber);
console.log('isInteger :', integers, '| unsafe:', unsafe);
console.log('NaN       :', nanCheck, '| separators:', separators);

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('Number demands the whole string; the parsers take a prefix and stop', wholeOrNothing, [true, 12, 3.5]);
test('so parseInt stops dead at the e that Number reads as an exponent', exponent, [1, 1000, 1000]);
test('an empty or blank string is zero, which is how "free" becomes ₦0.00', blanks, [0, 0, 0]);
test('checking the stripped string first turns that into an honest null', guarded, [125050, null, 20000]);
test('parseFloat stops at the comma, and parseInt reads the 5 out of 5e-7', [commaStops, tinyNumber], [1, 5]);
test('isInteger is about the value, not the type, the notation or the safety', integers, [true, true, false, false]);
test('past MAX_SAFE_INTEGER two different integers compare equal', unsafe, [true, 9007199254740991]);
test('the global isNaN coerces first; Number.isNaN never does', nanCheck, [true, false, true]);
test('a numeric separator is a source-code feature, invisible to the parser', separators, [1000, true]);

The three converters disagree on purpose. Number is all-or-nothing: leading and trailing space is fine, one leftover character is NaN. parseInt and parseFloat read as far as they can and return what they got, which is why parseFloat('1,250.50') is 1 and not an error — the worst kind of wrong answer, because it is a plausible number. For money, strip the formatting yourself and use Number, so anything unexpected shows up as NaN instead of a silent truncation.

Number('') being 0 is the bug this exercise is really about. Strip the non-digits out of free and you are left with an empty string, which becomes zero, which becomes a ₦0.00 order that looks deliberate. The one-line guard is to test the cleaned string before converting it. Always pass a radix to parseInt too: parseInt('0x1f') is 31, and the same function handed a small float reads its exponent notation as digits.

Number.isInteger checks the value, and every JavaScript number is a float, so 5.0 is an integer and 5e20 is one as well. It is not a validator: it says false for the string '5', and true for numbers so large that arithmetic on them has stopped being exact — that is what Number.isSafeInteger is for. Kobo as an integer keeps you well inside the safe range and away from 0.1 + 0.2 entirely, which is why money belongs in the smallest unit.

87 What await accepts, and what async hands back

fair

Show that an async function returns a promise no matter what its body returns, that await is happy with a plain value or with any object that has a then method, and that a throw inside becomes a rejection. Then find where the body of an async function actually stops running.

Stuck? Three hints, smallest first
  1. 1. Call an async function without awaiting it and look at what you are holding: check typeof it.then.
  2. 2. An object with a then(resolve, reject) method is a thenable, and await treats it like a promise. Promise.resolve wraps it; a native promise it hands straight back.
  3. 3. Put a push before the first await and one after it, then read the array before awaiting the call.

The answer

const fareAsync = async (km) => km * 1500;
const pending = fareAsync(10);
const kind = [typeof pending.then, pending instanceof Promise];
const fare = await pending;

const trace = [];
const dispatch = async () => {
  trace.push('body');
  await null;                       // the first suspension, however trivial
  trace.push('after');
};
const running = dispatch();
const atOnce = [...trace];
await running;
const atEnd = [...trace];

let thenCalls = 0;
const thenable = {
  then(resolve) {
    thenCalls++;
    resolve('paid by thenable');
  },
};
const fromThenable = await thenable;

const dry = async () => {
  throw new Error('no fuel');
};
let caught;
try {
  await dry();
} catch (error) {
  caught = error.message;
}
const viaCatch = await dry().catch((error) => `handled: ${error.message}`);

const inner = Promise.resolve('adopted');
const outer = async () => inner;         // returning a promise, not a value
const once = await outer();
const twice = await await Promise.resolve('one level only');

const native = Promise.resolve(1);
const identity = [Promise.resolve(native) === native, Promise.resolve(thenable) === thenable];

const parse = (src) => {
  try { new Function(src); return 'compiled'; } catch (error) { return error.constructor.name; }
};
const outsideAsync = parse('return await Promise.resolve(1);');

console.log('returned  :', kind, '| value:', fare);
console.log('trace     :', atOnce, '->', atEnd);
console.log('thenable  :', fromThenable, '| then called', thenCalls, 'time(s)');
console.log('rejection :', caught, '|', viaCatch);
console.log('adoption  :', once, twice, '| identity:', identity, '| outside async:', outsideAsync);

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 async function returns a promise, whatever its body returns', kind, ['function', true]);
test('and the returned value comes out of the await', fare, 15000);
test('the body runs synchronously up to the first await, then stops', [atOnce, atEnd], [['body'], ['body', 'after']]);
test('await accepts any object with a then method, and calls it once', [fromThenable, thenCalls], ['paid by thenable', 1]);
test('a throw inside an async function is a rejection, catchable with try', caught, 'no fuel');
test('and the same rejection is reachable through .catch on the promise', viaCatch, 'handled: no fuel');
test('a returned promise is adopted, so one await is enough and two change nothing', [once, twice], ['adopted', 'one level only']);
test('Promise.resolve returns a native promise unchanged, but wraps a thenable', identity, [true, false]);
test('await outside an async function does not compile at all', outsideAsync, 'SyntaxError');

async is a wrapper on the return value and await is a wrapper on the argument, and both are more forgiving than they look. Whatever the body returns comes back as a promise, so typeof x.then === 'function' is the only thing a caller can rely on. Whatever you await is put through Promise.resolve first, so awaiting 41, a thenable from some library, or a real promise all work — which is what makes await safe to write in front of a value that might or might not be asynchronous.

The trace is the part people get wrong. Calling an async function does not schedule it for later; the body runs immediately, on the spot, until it reaches the first await. Everything after that await is a continuation queued as a microtask. So a synchronous side effect at the top of an async function has already happened by the time the call returns, while the same line moved one place down has not.

Two things follow. A throw anywhere in the body becomes a rejection of the returned promise rather than an exception at the call site, so it is invisible unless someone awaits or catches it — an async function whose result is dropped swallows its own errors. And returning a promise from an async function adopts it rather than nesting it: there is no Promise<Promise<T>> to unwrap, which is why await await p is the same as await p.

88 for await...of, and who closes the generator

fair

Read a list of promises with for await...of, then write an async function* that awaits inside itself and yields the result. Prove the producer and the loop body take turns, find out what a break does to a generator holding a finally, and catch a rejection that lands in the middle of the loop.

Stuck? Three hints, smallest first
  1. 1. for await (const x of things) awaits every value on its way out, so x is never a promise.
  2. 2. An async function* may use await and yield in the same body. Wrap its loop in try { ... } finally { ... } and watch when the finally runs.
  3. 3. A break out of the loop calls return() on the iterator, and a rejected value throws at the point of the await, so try around the whole loop catches it.

The answer

const settle = (value) => Promise.resolve(value);      // stands in for one request

const arrived = [];
for await (const name of ['Adaeze', 'Chinedu', 'Yetunde'].map(settle)) arrived.push(name);

let closes = 0;
async function* riders(names) {
  try {
    for (const name of names) {
      const checked = await settle(name.toUpperCase());
      yield checked;
    }
  } finally {
    closes++;                                          // runs however the loop ends
  }
}

const upper = [];
for await (const name of riders(['adaeze', 'chinedu'])) upper.push(name);
const closesAfterFull = closes;

const partial = [];
for await (const name of riders(['adaeze', 'chinedu', 'yetunde'])) {
  partial.push(name);
  if (partial.length === 1) break;
}
const closesAfterBreak = closes;

const woven = [];
async function* two() {
  woven.push('yielded a');
  yield 'a';
  woven.push('yielded b');
  yield 'b';
}
for await (const value of two()) woven.push('body ' + value);

const seen = [];
let loopError;
try {
  for await (const one of [settle('ok'), Promise.reject(new Error('rider offline')), settle('never read')]) {
    seen.push(one);
  }
} catch (error) {
  loopError = error.message;
}

const plainValues = [];
for await (const kobo of [12000, 25000]) plainValues.push(kobo);

const it = riders(['bola'])[Symbol.asyncIterator]();
const isOwnIterator = it[Symbol.asyncIterator]() === it;
const step = await it.next();
const end = await it.next();
const byHand = [step.value, step.done, end.done, end.value === undefined];

console.log('promises  :', arrived, '| plain values:', plainValues);
console.log('generator :', upper, '| closes after a full loop:', closesAfterFull);
console.log('break     :', partial, '| closes after a break:', closesAfterBreak);
console.log('interleave:', woven);
console.log('rejection :', seen, loopError, '| by hand:', byHand, isOwnIterator);

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('for await over promises hands you values, in order, never promises', arrived, ['Adaeze', 'Chinedu', 'Yetunde']);
test('an async generator may await inside and yield the result', upper, ['ADAEZE', 'CHINEDU']);
test('running the loop out closes the generator, so its finally runs', closesAfterFull, 1);
test('and a break closes it too, by calling return on the iterator', [partial, closesAfterBreak], [['ADAEZE'], 2]);
test('producer and body take turns; nothing is computed ahead of time', woven, ['yielded a', 'body a', 'yielded b', 'body b']);
test('a rejected value throws at the await, so try around the loop catches it', [seen, loopError], [['ok'], 'rider offline']);
test('and the loop stops there, never reaching the third entry', seen.length, 1);
test('for await is happy with plain values too, promise or not', plainValues, [12000, 25000]);
test('the loop is only calling next() and unwrapping what comes back', byHand, ['BOLA', false, true, true]);

for await...of is a sequential reader, and that is the whole point of it. Each turn awaits one value before the body runs, so three requests read this way take the sum of their times, not the maximum. When the items are independent and you want them at once, that is Promise.all; when each one depends on the last, or the source produces them one at a time like a paged API, for await is the right shape. Note that it accepts plain values as well, so a list that is only sometimes asynchronous still works.

An async function* is the producing half: await for the fetching, yield for the handing over, and nothing runs until the consumer asks. The interleaved trace above is the evidence — the second value is not produced while the body is still working on the first.

The finally is the part worth internalising. A break, a return or a thrown error inside the loop calls return() on the iterator, which resumes the generator at its suspension point purely to run the cleanup. So a connection opened before the loop and closed in a finally is closed even when the consumer walks away early. What it does not do is unsubscribe from work already in flight: a rejection ends the loop, and any promise created before it was created eagerly and is still running, which is why a stray Promise.reject in a list can leave an unhandled rejection behind if the loop never reaches it.

89 all, allSettled, any and race, judged on a rejection

hard

Ask three Lagos markets for a quote at the same time, where one of them fails, and put all four combinators through it. Show what each one gives back, what happens to the losers, what Promise.any throws when everybody fails, and what each does with an empty list.

Stuck? Three hints, smallest first
  1. 1. Order things with microtask ticks rather than timers: chain .then a few times to make one job settle after another.
  2. 2. all rejects on the first rejection, allSettled never rejects, any wants one success, race takes the first settlement of either kind.
  3. 3. For the empty list, do not await Promise.race([]). Attach a handler that sets a flag, drain some ticks, then assert the flag never changed.

The answer

const after = (ticks, value) => {
  let p = Promise.resolve();
  for (let i = 0; i < ticks; i++) p = p.then((v) => v);
  return p.then(() => value);
};

const finished = [];
const quote = (market, ticks) =>
  after(ticks, market).then((name) => {
    finished.push(name);
    return name;
  });
const outOfStock = (market, ticks) =>
  after(ticks, market).then(() => {
    finished.push(market + ' failed');
    throw new Error(market + ' is out of stock');
  });

let allError;
try {
  await Promise.all([quote('Balogun', 1), outOfStock('Oyingbo', 3), quote('Mile12', 9)]);
} catch (error) {
  allError = error.message;
}
const atRejection = [...finished];
await after(20, null);
const afterwards = [...finished];

const settled = (await Promise.allSettled([quote('Ketu', 1), outOfStock('Ojota', 2)])).map((one) => [
  one.status,
  one.status === 'fulfilled' ? one.value : one.reason.message,
]);

const anyValue = await Promise.any([outOfStock('Agege', 1), quote('Yaba', 4), quote('Epe', 8)]);

let aggregate;
try {
  await Promise.any([outOfStock('Ajah', 1), outOfStock('Ikorodu', 2)]);
} catch (error) {
  aggregate = [error.constructor.name, error.errors.map((e) => e.message)];
}

const raced = await Promise.race([quote('Surulere', 5), quote('Lekki', 1)]);
let racedError;
try {
  await Promise.race([outOfStock('Badagry', 1), quote('Apapa', 5)]);
} catch (error) {
  racedError = error.message;
}

const emptyAll = await Promise.all([]);
const emptySettled = await Promise.allSettled([]);
let emptyAny;
try {
  await Promise.any([]);
} catch (error) {
  emptyAny = [error.constructor.name, error.errors.length];
}

let raceEmptyDone = false;
Promise.race([]).then(() => { raceEmptyDone = true; }, () => { raceEmptyDone = true; });
await after(30, null);
const raceEmptyPending = raceEmptyDone === false;

const inOrder = await Promise.all([quote('slow', 9), quote('fast', 1)]);

console.log('all       :', allError, '| at the rejection:', atRejection);
console.log('losers    :', afterwards);
console.log('allSettled:', settled, '| any:', anyValue);
console.log('all failed:', aggregate, '| race:', raced, racedError);
console.log('empty     :', emptyAll, emptySettled, emptyAny, '| race([]) pending:', raceEmptyPending);
console.log('order     :', inOrder);

Output


      
9 checks that prove this answer

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

9 checks

test('all rejects with the reason of the first rejection to happen', allError, 'Oyingbo is out of stock');
test('the losers are not cancelled: the slow one finishes long afterwards', [atRejection, afterwards], [['Balogun', 'Oyingbo failed'], ['Balogun', 'Oyingbo failed', 'Mile12']]);
test('allSettled never rejects; every outcome comes back labelled', settled, [['fulfilled', 'Ketu'], ['rejected', 'Ojota is out of stock']]);
test('any ignores rejections and resolves with the first success', anyValue, 'Yaba');
test('if every one fails, any throws an AggregateError holding all the reasons', aggregate, ['AggregateError', ['Ajah is out of stock', 'Ikorodu is out of stock']]);
test('race takes the first to settle, and a rejection counts as settling', [raced, racedError], ['Lekki', 'Badagry is out of stock']);
test('of an empty list, all and allSettled resolve at once and any rejects at once', [emptyAll, emptySettled, emptyAny], [[], [], ['AggregateError', 0]]);
test('but race of an empty list is pending for ever, so never pass it one', raceEmptyPending, true);
test('all keeps the order you passed in, not the order things finished', inOrder, ['slow', 'fast']);

Choose by what you want to happen to the failures. all is for a set where any failure ruins the answer: it rejects with the first reason and gives you nothing else, and the values it did collect are lost. allSettled is for a batch where each row stands alone — it always fulfils, with a { status, value } or { status, reason } for every entry, which is what you want for a report of twenty uploads where three failed. any is for redundancy: several sources, first usable answer wins, and it only fails if they all do. race is for whichever finishes first, including a timeout you deliberately put in the list.

None of them cancels anything. That is the assertion above worth staring at: Promise.all rejected, and the third market still finished its work and pushed its name afterwards. A promise is a handle on work already in flight, not a controller of it, so the losers of a race keep running, their side effects still happen, and their rejections still need somewhere to go. Cancellation needs an AbortController threaded through the work itself.

The empty cases are asymmetric and the last one is a real hang. all([]) and allSettled([]) fulfil immediately, any([]) rejects immediately with an AggregateError whose errors array is empty, and race([]) never settles at all — so a race over a filtered list that turned out empty is a request that hangs for ever with no error to show for it. Two smaller notes: all preserves input order regardless of completion order, so destructuring the result is safe; and AggregateError keeps the reasons on .errors rather than in .message, which is where people look first.

90 Sequential against parallel awaits, on a clock you inject

hard

Run three dispatch trips of 100ms each, first with an await inside a loop and then with Promise.all, and read the finishing times off an injected clock rather than a real one. Then show that it is where you call the async function that decides, not where you await it, and that a genuinely dependent chain cannot be flattened.

Stuck? Three hints, smallest first
  1. 1. Give the clock a wait(ms) that parks a resolver in a list, and an advance(ms) that resolves the due ones in time order.
  2. 2. advance must let pending microtasks run before it moves the time, or code that is mid-attempt registers its next wait against the wrong now and is stranded.
  3. 3. Sequential is for (const job of jobs) out.push(await job()). Parallel is Promise.all(jobs.map((job) => job())) — note that every call has happened before the first await.

The answer

const makeClock = () => {
  let now = 0;
  let waiting = [];
  const drain = async () => {
    for (let i = 0; i < 40; i++) await Promise.resolve();
  };
  return {
    now: () => now,
    wait(ms) {
      return new Promise((resolve) => waiting.push({ at: now + ms, resolve }));
    },
    async advance(ms) {
      const target = now + ms;
      await drain();                                    // let waits register FIRST
      let due = waiting.filter((w) => w.at <= target).sort((a, b) => a.at - b.at)[0];
      while (due) {
        waiting = waiting.filter((w) => w !== due);
        now = due.at;
        due.resolve();
        await drain();                                  // whatever woke up may wait again
        due = waiting.filter((w) => w.at <= target).sort((a, b) => a.at - b.at)[0];
      }
      now = target;
      await drain();
    },
  };
};

const trip = (name, ms) => async (clock) => {
  await clock.wait(ms);
  return name + '@' + clock.now();
};

const sequential = async (clock, jobs) => {
  const out = [];
  for (const job of jobs) out.push(await job(clock));
  return out;
};
const parallel = (clock, jobs) => Promise.all(jobs.map((job) => job(clock)));

const three = () => [trip('Ikeja', 100), trip('Yaba', 100), trip('Lekki', 100)];

const c1 = makeClock();
const seqRun = sequential(c1, three());
await c1.advance(300);
const seq = await seqRun;
const seqAt = c1.now();

const c2 = makeClock();
const parRun = parallel(c2, three());
await c2.advance(100);
const par = await parRun;
const parAt = c2.now();

const c3 = makeClock();
const held1 = trip('first', 100)(c3);                   // called now
const held2 = trip('second', 100)(c3);                  // called now as well
await c3.advance(100);
const bothStarted = [await held1, await held2, c3.now()];

const c4 = makeClock();
const mixedPar = parallel(c4, [trip('quick', 50), trip('slow', 250)]);
await c4.advance(250);
const mixedParOut = [await mixedPar, c4.now()];

const c5 = makeClock();
const mixedSeq = sequential(c5, [trip('quick', 50), trip('slow', 250)]);
await c5.advance(300);
const mixedSeqOut = [await mixedSeq, c5.now()];

const c6 = makeClock();
const cancelled = async (clock) => {
  await clock.wait(30);
  throw new Error('rider cancelled');
};
const guarded = Promise.all([cancelled(c6), trip('other', 200)(c6)]).catch((error) => error.message);
await c6.advance(30);
const failure = [await guarded, c6.now()];

const c7 = makeClock();
const lookupFare = async (clock, area) => {
  await clock.wait(80);
  return area === 'Lekki' ? 250000 : 120000;
};
const applyPromo = async (clock, kobo) => {
  await clock.wait(20);
  return kobo - 20000;
};
const chained = (async () => applyPromo(c7, await lookupFare(c7, 'Lekki')))();
await c7.advance(100);
const chainedOut = [await chained, c7.now()];

console.log('sequential:', seq, 'at', seqAt);
console.log('parallel  :', par, 'at', parAt);
console.log('two calls :', bothStarted);
console.log('uneven    :', mixedParOut, 'against', mixedSeqOut);
console.log('rejection :', failure, '| dependent chain:', chainedOut);

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('awaited one at a time, each 100ms trip starts where the last one ended', seq, ['Ikeja@100', 'Yaba@200', 'Lekki@300']);
test('so the clock has moved by the sum of them', seqAt, 300);
test('started together, all three land at the same moment', par, ['Ikeja@100', 'Yaba@100', 'Lekki@100']);
test('and the clock has moved by one trip, not three', parAt, 100);
test('two awaits in a row are still parallel, because both calls already happened', bothStarted, ['first@100', 'second@100', 100]);
test('under Promise.all the cost is the longest job, not the total', mixedParOut, [['quick@50', 'slow@250'], 250]);
test('the same two in a loop cost the sum, and the second starts late', mixedSeqOut, [['quick@50', 'slow@300'], 300]);
test('a rejection surfaces the moment it happens, while the other is still waiting', failure, ['rider cancelled', 30]);
test('but a chain whose second step needs the first is the sum, and no combinator helps', chainedOut, [230000, 100]);

await in a loop is the most expensive habit in async JavaScript, and the fix is not to remove the awaits. Look at the two shapes: Promise.all(jobs.map((job) => job())) calls every function before awaiting anything, so all three trips are in flight at once. The loop calls the second function only after the first has resolved. The third assertion isolates it — two plain awaits one after another are fully parallel, because the calls that started the work both happened earlier. Awaiting does not start work; calling does.

The last assertion is the honest limit. When step two needs step one’s value, the total is the sum and no combinator can change that, so before reaching for Promise.all ask whether the items are genuinely independent. Where they are, the win is the difference between the maximum and the total, which is why the uneven pair costs 250 in parallel and 300 in sequence.

The clock is the technique that makes any of this testable. Real timers turn a test of backoff or a timeout into a test that sleeps, and sleeping tests get deleted. An injected clock with wait and advance turns elapsed time into an assertion about a number. One rule makes it work, and it is the second hint: advance must drain pending microtasks before moving the time forward. Move the clock first and code that is between two waits registers its next one against a now that has already jumped, so it is never due and the test hangs on a promise that will not settle.

91 deep equals, and the cases === cannot see

hard

Write deepEquals(a, b) that walks two values and reports whether they hold the same data. Handle nested objects and arrays, Date, Map and Set, and get NaN right. Then show the two places it still disagrees with what you might expect: an explicit undefined value, and -0.

Stuck? Three hints, smallest first
  1. 1. Start with if (a === b) return true. That one line settles every primitive and every case where both sides are the same object.
  2. 2. Number.isNaN(a) && Number.isNaN(b) is the only place === is wrong, so it is the only special case you need before the object walk.
  3. 3. An array and { 0: x, 1: y, length: 2 } hold the same data but are not the same kind of thing. Object.getPrototypeOf tells them apart in one comparison.

The answer

const deepEquals = (a, b) => {
  if (a === b) return true;                                   // primitives, and the same reference
  if (Number.isNaN(a) && Number.isNaN(b)) return true;        // the one thing === gets wrong
  if (typeof a !== 'object' || typeof b !== 'object') return false;
  if (a === null || b === null) return false;
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
  if (a instanceof Date) return a.getTime() === b.getTime();
  if (Array.isArray(a)) return a.length === b.length && a.every((v, i) => deepEquals(v, b[i]));
  if (a instanceof Map) {
    if (a.size !== b.size) return false;
    for (const [k, v] of a) if (!b.has(k) || !deepEquals(v, b.get(k))) return false;
    return true;
  }
  if (a instanceof Set) {
    if (a.size !== b.size) return false;
    for (const v of a) if (!b.has(v)) return false;
    return true;
  }
  const keys = Object.keys(a);
  if (keys.length !== Object.keys(b).length) return false;
  return keys.every((k) => Object.hasOwn(b, k) && deepEquals(a[k], b[k]));
};

const joined = '2022-05-01T00:00:00.000Z';
const left = { name: 'Chidi', lga: 'Surulere', roles: ['rider', 'lead'], meta: { tier: 2, since: new Date(joined) } };
const right = { name: 'Chidi', lga: 'Surulere', roles: ['rider', 'lead'], meta: { tier: 2, since: new Date(joined) } };
const reordered = { lga: 'Surulere', meta: { since: new Date(joined), tier: 2 }, roles: ['rider', 'lead'], name: 'Chidi' };
const oneLeafOff = { ...right, meta: { ...right.meta, tier: 3 } };
const arrayLike = { 0: 'rider', 1: 'lead', length: 2 };

const stockA = new Map([['GRP-1', 2], ['GRP-2', 5]]);
const stockB = new Map([['GRP-2', 5], ['GRP-1', 2]]);
const stockShort = new Map([['GRP-1', 2]]);
const zonesA = new Set(['ikeja', 'yaba', 'lekki']);
const zonesB = new Set(['lekki', 'yaba', 'ikeja']);

const withUndefined = { id: 'AC-9', otp: undefined };
const withoutOtp = { id: 'AC-9' };

console.log('same data   :', deepEquals(left, right), '| same object:', left === right);
console.log('key order   :', deepEquals(right, reordered), '| one leaf off:', deepEquals(left, oneLeafOff));
console.log('collections :', deepEquals(stockA, stockB), deepEquals(zonesA, zonesB));
console.log('undefined   :', deepEquals(withUndefined, withoutOtp), '| JSON says:', same(withUndefined, withoutOtp));

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 objects built separately hold the same data, and === still says no', [deepEquals(left, right), left === right], [true, false]);
test('key order is not part of the value', deepEquals(right, reordered), true);
test('one leaf out of place is enough to fail', deepEquals(left, oneLeafOff), false);
test('NaN equals itself here, which is the one place === is wrong', [deepEquals(NaN, NaN), NaN === NaN], [true, false]);
test('an array and an array-like are different kinds of thing', deepEquals(left.roles, arrayLike), false);
test('Maps compare by entries, and insertion order is not part of it', [deepEquals(stockA, stockB), deepEquals(stockA, stockShort)], [true, false]);
test('Sets compare by membership', deepEquals(zonesA, zonesB), true);
test('an explicit undefined is still a key, so the walk and JSON disagree', [deepEquals(withUndefined, withoutOtp), same(withUndefined, withoutOtp)], [false, true]);
test('0 and -0 slip through as equal, because === says they are', [deepEquals(0, -0), Object.is(0, -0)], [true, false]);

The first line does most of the work. a === b settles every number, string, boolean, null, undefined, symbol and every case where both sides are literally the same object, which means the recursive part only ever runs on two different objects. After that the shape of the function is a list of the kinds of thing JavaScript has: array, Date, Map, Set, plain object. There is no generic answer, because there is no generic way to ask an object what it contains.

The prototype check earns its place twice. It separates an array from an array-like, and it separates a plain object from a class instance with the same fields, which is usually what you want: a Naira and a bare { amount } are not the same value even when they print the same.

Two disagreements are left in on purpose. { otp: undefined } and {} are not equal here, because they have different key counts, while JSON.stringify flattens them to the same string — which is why a deep equals built on stringify quietly passes a bug through. And -0 compares equal to 0, because === says so. If that matters, swap the first line for Object.is(a, b), which separates them and handles NaN for free, letting you delete the second line.

92 a deep clone that survives a cycle

hard

Write deepClone(value) that copies nested objects, arrays, Date, Map and Set, and does not fall into an endless loop when the data points back at itself. Prove the cycle came out as a cycle, prove two references to one object stay two references to one copy, and show what the clone quietly drops.

Stuck? Three hints, smallest first
  1. 1. The cycle problem is a memo problem: keep a record of "I have already started copying this object, and here is the copy".
  2. 2. A WeakMap from original to copy is the record. Put the empty copy in it before you fill it, or the recursion reaches the cycle before the entry exists.
  3. 3. Recursion carries the memo as a default parameter: (value, seen = new WeakMap()). Fresh at the top call, shared all the way down.

The answer

const deepClone = (value, seen = new WeakMap()) => {
  if (value === null || typeof value !== 'object') return value;   // primitives and functions
  if (seen.has(value)) return seen.get(value);                     // the cycle stops here
  if (value instanceof Date) return new Date(value.getTime());
  if (value instanceof Map) {
    const copy = new Map();
    seen.set(value, copy);
    for (const [k, v] of value) copy.set(deepClone(k, seen), deepClone(v, seen));
    return copy;
  }
  if (value instanceof Set) {
    const copy = new Set();
    seen.set(value, copy);
    for (const v of value) copy.add(deepClone(v, seen));
    return copy;
  }
  const copy = Array.isArray(value) ? [] : {};
  seen.set(value, copy);                                           // BEFORE filling it
  for (const [k, v] of Object.entries(value)) copy[k] = deepClone(v, seen);
  return copy;
};

const REF = Symbol('internal ref');
const vendor = {
  name: 'Ngozi Traders',
  stock: [{ sku: 'GRP-1', units: 12 }, { sku: 'GRP-2', units: 3 }],
  joined: new Date('2023-02-14T00:00:00.000Z'),
  tags: new Set(['wholesale', 'verified']),
  prices: new Map([['GRP-1', 4500]]),
  greet() { return 'Nno'; },
};
vendor[REF] = 'v-01';

const copy = deepClone(vendor);
const unitsBefore = copy.stock[0].units;
copy.stock[0].units = 99;
const unitsInCopy = copy.stock[0].units;
const unitsInOriginal = vendor.stock[0].units;

const branch = { name: 'Onitsha', parent: null };
const chain = { name: 'HQ', branches: [branch] };
branch.parent = chain;                                             // the cycle
const chainCopy = deepClone(chain);

const shared = { vat: 0.075 };
const twice = { retail: shared, wholesale: shared };
const twiceCopy = deepClone(twice);

let jsonError = 'no error';
try { JSON.stringify(chain); } catch (e) { jsonError = e.constructor.name; }

console.log('deep copy :', copy.name, copy.stock, '| original:', vendor.stock[0].units);
console.log('cycle     :', chainCopy.branches[0].parent === chainCopy);
console.log('alias     :', twiceCopy.retail === twiceCopy.wholesale);
console.log('JSON      :', jsonError);

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 clone holds the same data in different objects', [copy.name, unitsBefore, copy === vendor, copy.stock === vendor.stock], ['Ngozi Traders', 12, false, false]);
test('so writing deep inside the clone leaves the original alone', [unitsInCopy, unitsInOriginal], [99, 12]);
test('a Date comes back a Date, same instant, new identity', [copy.joined instanceof Date, copy.joined === vendor.joined, copy.joined.toISOString()], [true, false, '2023-02-14T00:00:00.000Z']);
test('Map and Set are rebuilt rather than shared', [[...copy.prices], [...copy.tags], copy.tags === vendor.tags], [[['GRP-1', 4500]], ['wholesale', 'verified'], false]);
test('the cycle is a cycle again, and it points at the copy', [chainCopy.branches[0].parent === chainCopy, chainCopy.branches[0].parent === chain], [true, false]);
test('two references to one object stay two references to one copy', twiceCopy.retail === twiceCopy.wholesale, true);
test('the JSON round trip cannot do that at all', jsonError, 'TypeError');
test('a function is carried across by reference, not copied', [copy.greet === vendor.greet, copy.greet()], [true, 'Nno']);
test('Object.entries never sees symbol keys, so the clone drops them', [Object.getOwnPropertySymbols(vendor).length, Object.getOwnPropertySymbols(copy).length], [1, 0]);

The memo is the whole exercise, and the order of two lines is the whole memo. seen.set(value, copy) has to run before the loop that fills copy, because the recursion can come back around to value while that loop is still running. Set it after and the guard never fires: the function calls itself for ever and the panel dies. A WeakMap rather than a Map so that finishing the clone does not pin the originals in memory.

The same memo gives you something subtler for free. { retail: shared, wholesale: shared } is one object referenced twice, and the clone preserves that: one copy, referenced twice. A naive clone would produce two independent copies, and code that writes through retail expecting wholesale to follow would silently stop working.

What it drops is worth knowing before you rely on it: symbol keys and non-enumerable properties, because Object.entries lists neither; the prototype, because a class instance comes back as a plain object; and getters, which are read once and frozen into plain values. Functions come across by reference, which is usually what you want and is exactly what the built-in structuredClone refuses to do — it handles cycles, Dates, Maps, Sets and typed arrays properly, and throws on a function or a DOM node. Reach for it first; write this when you need the parts it will not take.

93 get and set by dotted path

fair

Write getPath(obj, 'buyer.address.lga', fallback) that walks a dotted string without throwing on a missing branch, and setPath(obj, path, value) that creates the objects it needs on the way down, choosing an array when the next segment is a number. Then find the two paths that cannot mean what they look like.

Stuck? Three hints, smallest first
  1. 1. split('.') gives the segments; a for...of that reassigns a cursor walks them.
  2. 2. Bail out the moment the cursor is null or undefined, before you index into it. That is the whole difference from obj.a.b.c throwing.
  3. 3. For set, the last segment is the one you assign to, so pop it off a copy of the list first. What to create for a missing segment depends on the segment after it.

The answer

const looksLikeIndex = (key) => String(Number(key)) === String(key) && Number(key) >= 0;

const getPath = (obj, path, fallback) => {
  const keys = Array.isArray(path) ? path : String(path).split('.');
  let cursor = obj;
  for (const key of keys) {
    if (cursor === null || cursor === undefined) return fallback;
    cursor = cursor[key];
  }
  return cursor === undefined ? fallback : cursor;
};

const setPath = (obj, path, value) => {
  const keys = Array.isArray(path) ? [...path] : String(path).split('.');
  const leaf = keys.pop();
  let cursor = obj;
  for (const [i, key] of keys.entries()) {
    const next = cursor[key];
    if (next === null || typeof next !== 'object') {
      cursor[key] = looksLikeIndex(keys[i + 1] ?? leaf) ? [] : {};
    }
    cursor = cursor[key];
  }
  cursor[leaf] = value;
  return obj;
};

const order = {
  id: 'ORD-771',
  buyer: { name: 'Amaka', address: { lga: 'Yaba', city: 'Lagos' } },
  lines: [{ sku: 'GRP-1', units: 2 }, { sku: 'GRP-2', units: 1 }],
};
const hollow = { a: { b: undefined } };
const odd = { 'buyer.name': 'Ngozi' };

const built = setPath({}, 'vendor.contact.phone', '0803 000 0000');
const withArray = setPath({}, 'lines.0.sku', 'GRP-9');
const target = { buyer: { address: { lga: 'Yaba' } } };
const returned = setPath(target, 'buyer.address.lga', 'Surulere');

console.log('get    :', getPath(order, 'buyer.address.lga'), '| missing:', getPath(order, 'buyer.bank.name', 'unknown'));
console.log('index  :', getPath(order, 'lines.1.sku'));
console.log('built  :', built, '| array:', withArray);
console.log('dotted key:', getPath(odd, 'buyer.name', 'missing'), getPath(odd, ['buyer.name'], 'missing'));

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 dotted path walks down the object', getPath(order, 'buyer.address.lga'), 'Yaba');
test('a missing branch returns the fallback instead of throwing', getPath(order, 'buyer.bank.name', 'unknown'), 'unknown');
test('a numeric segment indexes an array, because obj[1] and obj["1"] are one key', getPath(order, 'lines.1.sku'), 'GRP-2');
test('a leaf that exists and holds undefined gets the fallback too', [getPath(hollow, 'a.b', 'fallback'), Object.hasOwn(hollow.a, 'b')], ['fallback', true]);
test('set fills in the objects it needs on the way down', [built.vendor.contact.phone, Object.keys(built)], ['0803 000 0000', ['vendor']]);
test('a numeric next segment makes an array, not an object with a "0" key', [Array.isArray(withArray.lines), withArray.lines.length, withArray.lines[0].sku], [true, 1, 'GRP-9']);
test('set mutates in place and hands back the same object', [returned === target, target.buyer.address.lga], [true, 'Surulere']);
test('a key containing a dot is unreachable by string, reachable by array', [getPath(odd, 'buyer.name', 'missing'), getPath(odd, ['buyer.name'], 'missing')], ['missing', 'Ngozi']);
test('an empty path is one empty segment, not "the whole object"', getPath(order, '', 'nothing'), 'nothing');

Optional chaining already does the safe read: order?.buyer?.address?.lga. The reason a path helper still exists is that the path is data — it came from a config file, a form field name, a column definition — and you cannot write ?. against a string you have not seen yet. That is the only case where this beats the syntax.

Two design decisions are worth naming. Returning the fallback for a leaf that genuinely holds undefined is a lie about the data, and it is the behaviour every library ships, because the alternative needs two return values. And set creating branches is convenient right up to the moment a typo in a path silently grows a new object where you expected an error.

The dotted-key assertion is the real limit. A dotted string is a lossy encoding of a key list: any key with a dot in it becomes unreachable, and no amount of escaping fixes it cleanly. Accepting an array as well as a string, as this one does, costs one line and makes the awkward case expressible.

94 pick, omit, pickBy and invert

easy

Build four object utilities out of Object.entries and Object.fromEntries: pick a list of keys, omit a list of keys, pickBy a predicate over the values, and invert to swap keys with values. Show that a rest destructure is omit with the keys written by hand, and find the two things invert does to your data.

Stuck? Three hints, smallest first
  1. 1. Object.entries gives [key, value] pairs, Object.fromEntries takes them back. Everything here is a filter or a map between the two.
  2. 2. For pick, map over the keys you were given, not over the object, so the result comes out in the order you asked for.
  3. 3. A key that is not in the object should not appear in the result at all, which is a filter before the map, not an undefined after it.

The answer

const pick = (source, keys) =>
  Object.fromEntries(keys.filter((k) => k in source).map((k) => [k, source[k]]));

const omit = (source, keys) =>
  Object.fromEntries(Object.entries(source).filter(([k]) => !keys.includes(k)));

const pickBy = (source, keep) =>
  Object.fromEntries(Object.entries(source).filter(([k, v]) => keep(v, k)));

const invert = (source) =>
  Object.fromEntries(Object.entries(source).map(([k, v]) => [v, k]));

const account = { id: 'AC-9', name: 'Bisi Adeyemi', bank: 'GTBank', balance: 48500, pin: '1234', otp: null };

const publicView = pick(account, ['name', 'bank']);
const withAbsentKey = pick(account, ['name', 'nin']);
const withoutSecrets = omit(account, ['pin', 'otp']);
const keysStillThere = Object.keys(account).length;
const filled = pickBy(account, (value) => value !== null);

const { pin, otp, ...rest } = account;
const restMatchesOmit = same(rest, withoutSecrets);

const zones = invert({ lekki: 'zone-a', ikeja: 'zone-b' });
const collapsed = invert({ ada: 'north', obi: 'north' });
const numbered = invert({ lekki: 1, ikeja: 2 });
const roundTrip = invert(numbered);

console.log('pick   :', publicView, '| absent key:', Object.keys(withAbsentKey));
console.log('omit   :', withoutSecrets, '| original keys:', keysStillThere);
console.log('invert :', zones, '| duplicates:', collapsed);
console.log('numbers:', numbered, '| back again:', roundTrip);

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('pick keeps the listed keys, in the order they were listed', publicView, { name: 'Bisi Adeyemi', bank: 'GTBank' });
test('a key that is not there is left out, not set to undefined', Object.keys(withAbsentKey), ['name']);
test('omit is the complement, and keeps the source order', Object.keys(withoutSecrets), ['id', 'name', 'bank', 'balance']);
test('neither one touches the original', keysStillThere, 6);
test('a rest destructure is omit with the keys written out', [restMatchesOmit, pin, otp], [true, '1234', null]);
test('pickBy filters on the value', Object.keys(filled), ['id', 'name', 'bank', 'balance', 'pin']);
test('invert swaps the pairs', zones, { 'zone-a': 'lekki', 'zone-b': 'ikeja' });
test('two keys sharing a value collapse to one, and the last write wins', collapsed, { north: 'obi' });
test('and every inverted key is a string, so a number does not survive the trip', [Object.keys(numbered), roundTrip.lekki, typeof roundTrip.lekki], [['1', '2'], '1', 'string']);

Object.entries and Object.fromEntries turn an object into a list and back, which means the whole vocabulary of array methods becomes available for objects. That is the point of this exercise more than the four functions: once you see the round trip, mapValues, mapKeys, filterKeys and the rest are one line each.

Prefer the rest destructure when the keys are known at authoring time — const { pin, ...rest } = account is clearer than any helper and needs no import. Reach for omit when the list of keys is a variable.

invert is the one with teeth. Object keys are strings, so every value becomes a string on the way in: invert twice and the number 1 comes back as '1'. And keys are unique, so two entries sharing a value collapse into one, last write winning, with no error. If either matters, invert into a Map instead, which keeps the key type, or into a Map of arrays, which keeps them all.

One more trap not asserted above: pick uses in, which sees inherited properties, so pick(account, ['toString']) happily returns a function. Swap in for Object.hasOwn if the key list is not trusted.

95 groupBy, countBy and indexBy over a Map

fair

Write the three collectors that turn a flat list into a lookup: groupBy to a list per key, countBy to a number per key, indexBy to one item per key. Let each take either a property name or a function. Return a Map, then show the two things you lose the moment you return a plain object instead.

Stuck? Three hints, smallest first
  1. 1. Both forms of the key argument reduce to one: typeof key === 'function' ? key : (item) => item[key].
  2. 2. For groupBy, check map.has(k) and seed an empty array before pushing. For countBy, map.set(k, (map.get(k) ?? 0) + 1) does the same job in one line.
  3. 3. indexBy is the one-liner: new Map(items.map((item) => [of(item), item])). Think about what happens when two items share a key.

The answer

const keyFn = (key) => (typeof key === 'function' ? key : (item) => item[key]);

const groupBy = (items, key) => {
  const of = keyFn(key);
  const out = new Map();
  for (const item of items) {
    const k = of(item);
    if (!out.has(k)) out.set(k, []);
    out.get(k).push(item);
  }
  return out;
};

const countBy = (items, key) => {
  const of = keyFn(key);
  const out = new Map();
  for (const item of items) {
    const k = of(item);
    out.set(k, (out.get(k) ?? 0) + 1);
  }
  return out;
};

const indexBy = (items, key) => {
  const of = keyFn(key);
  return new Map(items.map((item) => [of(item), item]));
};

const riders = [
  { name: 'Chidi', lga: 'Yaba', trips: 41 },
  { name: 'Amaka', lga: 'Ikeja', trips: 12 },
  { name: 'Bisi', lga: 'Yaba', trips: 33 },
  { name: 'Tunde', lga: 'Ikeja', trips: 12 },
  { name: 'Ngozi', lga: 'Surulere', trips: 7 },
];

const names = (list) => list.map((r) => r.name);
const byLga = [...groupBy(riders, 'lga')].map(([k, v]) => [k, names(v)]);
const byBand = [...groupBy(riders, (r) => (r.trips >= 30 ? 'busy' : 'quiet'))].map(([k, v]) => [k, names(v)]);
const perLga = [...countBy(riders, 'lga')];
const perTripCount = [...countBy(riders, 'trips')];
const numericKeyType = typeof [...countBy(riders, 'trips').keys()][0];
const lastPerLga = indexBy(riders, 'lga');
const asObject = Object.fromEntries(countBy(riders, 'trips'));
const boolKeys = [...groupBy(riders, (r) => r.trips > 20).keys()];

console.log('by lga  :', byLga);
console.log('by band :', byBand);
console.log('counts  :', perLga, '| by trips:', perTripCount);
console.log('indexed :', [...lastPerLga.keys()], '| Yaba ->', lastPerLga.get('Yaba').name);

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('grouping by a property gives a list per key, in first-seen order', byLga, [['Yaba', ['Chidi', 'Bisi']], ['Ikeja', ['Amaka', 'Tunde']], ['Surulere', ['Ngozi']]]);
test('a function key groups by anything you can compute', byBand, [['busy', ['Chidi', 'Bisi']], ['quiet', ['Amaka', 'Tunde', 'Ngozi']]]);
test('countBy is the same walk with a number instead of a list', perLga, [['Yaba', 2], ['Ikeja', 2], ['Surulere', 1]]);
test('a Map key keeps its type, so 12 stays the number 12', [perTripCount, numericKeyType], [[[41, 1], [12, 2], [33, 1], [7, 1]], 'number']);
test('indexBy keeps one item per key, and a repeat overwrites', [lastPerLga.size, lastPerLga.get('Yaba').name, lastPerLga.get('Ikeja').name], [3, 'Bisi', 'Tunde']);
test('turn it into a plain object and the numbers become strings, re-sorted ascending', Object.keys(asObject), ['7', '12', '33', '41']);
test('booleans survive as booleans in a Map', boolKeys, [true, false]);
test('nothing in, an empty Map out, not undefined', [groupBy([], 'lga').size, countBy([], 'lga').size], [0, 0]);

These three are the same loop with a different accumulator, which is why they belong in one exercise. Once the key argument is normalised to a function, the difference between them is one line in the body.

Returning a Map rather than an object is the decision worth defending. An object key is always a string, so grouping by trips gives you '12'; grouping by a boolean gives you 'true'; and a group named 'constructor' or '__proto__' collides with something already on Object.prototype. A Map has none of those problems, keeps insertion order for every key type, and gives you .size for free. The cost is that it will not JSON.stringify, which is why the assertions above all project through [...map].

The re-sorting in the object assertion is the detail people trip over: integer-like keys in a plain object are always enumerated in ascending numeric order, no matter what order you inserted them. Insertion order only holds for keys that do not look like array indices. If the order of your groups is meaningful, a Map is not a preference, it is a requirement.

Object.groupBy and Map.groupBy now ship in current engines and do exactly this. Knowing what the hand-written version does tells you which of the two to reach for.

96 chunk, zip, unzip and partition

fair

Write the four reshapers: chunk a list into fixed-size pieces, zip several lists into rows, unzip rows back into lists, and partition a list into the ones that match and the ones that do not. Decide what zip should do when the lists are different lengths, and make the choice visible.

Stuck? Three hints, smallest first
  1. 1. chunk is the one place a counting for loop still reads better than anything else: step i by size and slice.
  2. 2. Array.from({ length }, (_, i) => ...) builds a list of a known length from its indices, which is exactly what zip needs.
  3. 3. unzip needs no new code at all. Look at what zip(...rows) does to a list of rows.

The answer

const chunk = (items, size) => {
  if (!Number.isInteger(size) || size < 1) throw new RangeError('size must be a positive integer');
  const out = [];
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
  return out;
};

const zip = (...lists) => {
  const length = Math.min(...lists.map((l) => l.length));           // stop at the shortest
  return Array.from({ length }, (_, i) => lists.map((l) => l[i]));
};

const zipLong = (...lists) => {
  const length = Math.max(...lists.map((l) => l.length));           // pad instead
  return Array.from({ length }, (_, i) => lists.map((l) => l[i] ?? null));
};

const unzip = (rows) => zip(...rows);

const partition = (items, keep) => {
  const yes = [];
  const no = [];
  for (const item of items) (keep(item) ? yes : no).push(item);
  return [yes, no];
};

const skus = ['GRP-1', 'GRP-2', 'GRP-3', 'GRP-4', 'GRP-5'];
const units = [12, 3, 40, 7, 25];
const shelves = ['A1', 'A2'];

const pages = chunk(skus, 2);
const exact = chunk(skus.slice(0, 4), 2);
const emptyChunked = chunk([], 3);
const rows = zip(skus, units);
const backAgain = unzip(rows);
const short = zip(skus, shelves);
const padded = zipLong(shelves, units);
const [plenty, scarce] = partition(units, (n) => n >= 10);
const [none, alsoNone] = partition([], (n) => n > 0);

let badSize = 'no error';
try { chunk(skus, 0); } catch (e) { badSize = e.constructor.name; }

console.log('chunk    :', pages);
console.log('zip      :', rows.slice(0, 2), '| shortest wins:', short.length);
console.log('unzip    :', backAgain);
console.log('partition:', plenty, scarce, '| bad size:', badSize);

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('chunk cuts fixed-size pieces and lets the last one be short', pages, [['GRP-1', 'GRP-2'], ['GRP-3', 'GRP-4'], ['GRP-5']]);
test('an exact multiple has no short tail, and nothing chunks to nothing', [exact.length, emptyChunked], [2, []]);
test('zip pairs by position', rows.slice(0, 3), [['GRP-1', 12], ['GRP-2', 3], ['GRP-3', 40]]);
test('unzip is zip spread over the rows, so the round trip is the identity', backAgain, [skus, units]);
test('mismatched lengths stop at the shortest, silently losing the rest', [short.length, short], [2, [['GRP-1', 'A1'], ['GRP-2', 'A2']]]);
test('the other choice pads, and null says "nothing here" out loud', padded, [['A1', 12], ['A2', 3], [null, 40], [null, 7], [null, 25]]);
test('partition keeps the original order inside both halves', [plenty, scarce], [[12, 40, 25], [3, 7]]);
test('and partitioning nothing gives two empty lists, never undefined', [none, alsoNone], [[], []]);
test('a size of zero throws rather than building an infinite list of empties', badSize, 'RangeError');

chunk with a size of zero is the reason the guard exists: i += 0 never advances, so the loop pushes empty arrays for ever. Throwing a RangeError turns a hung tab into a stack trace pointing at the caller, which is the trade you want.

zip has no right answer for ragged input, only two defensible ones. Truncating to the shortest is what most libraries do and what most callers expect, but it discards data with no warning, so it is only safe when the lists are the same length by construction — which is usually the case, because they came from the same rows. Padding keeps everything and forces the caller to handle a hole. Padding with null rather than undefined is deliberate: undefined vanishes through JSON.stringify and reads as "missing key" instead of "missing value".

partition is worth having because the obvious alternative walks the list twice with two opposite predicates, and the second predicate is where the bug goes: n >= 10 and n < 10 drift apart the moment the rule changes. One walk, one predicate, two lists, and the destructure at the call site reads well.

97 uniq, uniqBy, and what Set counts as the same

easy

Dedupe a list two ways: uniq through a Set, and uniqBy(items, key) where the key is a property name or a function. Keep the first of each duplicate group. Then pin down exactly which values a Set treats as the same, because it is not what === or indexOf does.

Stuck? Three hints, smallest first
  1. 1. [...new Set(items)] is the whole of uniq, and it already keeps first-seen order.
  2. 2. For uniqBy the Set holds the keys, not the items, and a separate array collects the items you decided to keep.
  3. 3. Two objects are only ever the same value if they are the same object. If you want to dedupe by contents, the key function has to produce a primitive.

The answer

const uniq = (items) => [...new Set(items)];

const uniqBy = (items, key) => {
  const of = typeof key === 'function' ? key : (item) => item[key];
  const seen = new Set();
  const out = [];
  for (const item of items) {
    const k = of(item);
    if (!seen.has(k)) {
      seen.add(k);
      out.push(item);
    }
  }
  return out;
};

const payments = [
  { ref: 'PX-1', phone: '0803 111 2222', amount: 5000 },
  { ref: 'PX-2', phone: '08031112222', amount: 5000 },
  { ref: 'PX-3', phone: '0805 333 4444', amount: 1500 },
  { ref: 'PX-4', phone: '0803 111 2222', amount: 7000 },
];
const digitsOnly = (text) => [...text].filter((c) => c !== ' ').join('');
const refs = (list) => list.map((p) => p.ref);

const plain = uniq(['yaba', 'ikeja', 'yaba', 'lekki', 'ikeja']);
const nanCount = uniq([NaN, NaN, NaN]).length;
const indexOfNaN = [NaN].indexOf(NaN);
const zeroCount = uniq([0, -0]).length;
const looseCount = uniq(['1', 1]).length;
const objectCount = uniq([{ lga: 'Yaba' }, { lga: 'Yaba' }]).length;

const byPhone = refs(uniqBy(payments, 'phone'));
const byNormalised = refs(uniqBy(payments, (p) => digitsOnly(p.phone)));
const keptAmounts = uniqBy(payments, 'phone').map((p) => p.amount);
const byPair = refs(uniqBy(payments, (p) => `${digitsOnly(p.phone)}|${p.amount}`));

console.log('uniq      :', plain);
console.log('NaN       :', nanCount, '| indexOf says:', indexOfNaN);
console.log('by phone  :', byPhone, '| normalised:', byNormalised);
console.log('by pair   :', byPair);

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('uniq keeps the first of each value, in order', plain, ['yaba', 'ikeja', 'lekki']);
test('a Set treats NaN as equal to itself, which indexOf never does', [nanCount, indexOfNaN], [1, -1]);
test('0 and -0 collapse, but a string and a number never do', [zeroCount, looseCount], [1, 2]);
test('two objects with identical fields are two different values', objectCount, 2);
test('uniqBy on a property dedupes on that property', byPhone, ['PX-1', 'PX-2', 'PX-3']);
test('it keeps the first of a group, so the later amount is the one dropped', keptAmounts, [5000, 5000, 1500]);
test('a key function normalises before comparing, which catches the real duplicate', byNormalised, ['PX-1', 'PX-3']);
test('a template-string key dedupes on two fields at once', byPair, ['PX-1', 'PX-3', 'PX-4']);

A Set compares with SameValueZero, which is === with one repair and one wart. The repair: NaN equals itself, so a list of NaNs dedupes to one, while indexOf — which uses strict equality — can never find a NaN at all. The wart: 0 and -0 are treated as the same value, which is right for counting and wrong if the sign carries meaning, such as a debit direction.

Everything else follows from objects comparing by identity. uniq on a list of records does nothing useful, because no two records are ever the same value unless they are the same object. That is why uniqBy exists, and why its key function must return a primitive: a key function returning an object leaves you exactly where you started.

Two things about the key. Building it from a template string is fine until a field contains your separator, which is how '0803|111' and '0803', '111' end up as one group; pick a separator the data cannot contain, or nest Maps. And normalising inside the key — stripping the spaces from a phone number here — is where the real duplicates get caught, because raw data is almost never duplicated in exactly the same shape.

98 a comparator factory, and sorting by several keys

fair

Write byKey(key, direction) that returns a comparator, and then(...comparators) that runs them in order until one of them cares. Sort a list of market stalls by market ascending then takings descending. Then show why a - b and a boolean-returning comparator are both wrong more often than they look.

Stuck? Three hints, smallest first
  1. 1. A comparator answers one question with three possible answers: negative, zero, positive. Nothing else.
  2. 2. (x < y ? -1 : x > y ? 1 : 0) works for numbers and strings alike, and multiplying it by -1 is the whole of descending.
  3. 3. then loops over the comparators and returns the first non-zero answer. Returning 0 at the end is what keeps the sort stable.

The answer

const byKey = (key, direction = 'asc') => {
  const of = typeof key === 'function' ? key : (item) => item[key];
  const sign = direction === 'desc' ? -1 : 1;
  return (a, b) => {
    const x = of(a);
    const y = of(b);
    return (x < y ? -1 : x > y ? 1 : 0) * sign;
  };
};

const then = (...comparators) => (a, b) => {
  for (const compare of comparators) {
    const answer = compare(a, b);
    if (answer !== 0) return answer;
  }
  return 0;
};

const stalls = [
  { stall: 'A12', market: 'Balogun', takings: 12000 },
  { stall: 'B03', market: 'Alaba', takings: 30000 },
  { stall: 'A07', market: 'Balogun', takings: 30000 },
  { stall: 'C21', market: 'Alaba', takings: 9000 },
];
const ids = (list) => list.map((s) => s.stall);

const byTakings = ids([...stalls].sort(byKey('takings')));
const byTakingsDesc = ids([...stalls].sort(byKey('takings', 'desc')));
const grouped = ids([...stalls].sort(then(byKey('market'), byKey('takings', 'desc'))));
const byNameLength = ids([...stalls].sort(byKey((s) => s.market.length)));

const inPlace = [...stalls];
const returnedSame = inPlace.sort(byKey('takings')) === inPlace;
const originalUntouched = stalls[0].stall;

const asText = [10, 9, 100].sort();
const codeUnits = ['ada', 'Zainab', 'bola'].sort(byKey((s) => s));
const withLocale = ['ada', 'Zainab', 'bola'].sort((a, b) => a.localeCompare(b, 'en'));

const boolish = (a, b) => a > b;
const bothWays = [Number(boolish(1, 2)), Number(boolish(2, 1))];
const subtractive = (a, b) => a - b;
const onStrings = subtractive('bola', 'ada');

console.log('takings :', byTakings, '| desc:', byTakingsDesc);
console.log('grouped :', grouped);
console.log('default :', asText, '| code units:', codeUnits, '| locale:', withLocale);
console.log('boolean :', bothWays, '| subtraction on text:', onStrings);

Output


      
9 checks that prove this answer

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

9 checks

test('one key, ascending, ties left in their input order', byTakings, ['C21', 'A12', 'B03', 'A07']);
test('desc is the same comparator times minus one', byTakingsDesc, ['B03', 'A07', 'A12', 'C21']);
test('then runs the comparators until one of them cares', grouped, ['B03', 'C21', 'A07', 'A12']);
test('the key can be computed, not just named', byNameLength, ['B03', 'C21', 'A12', 'A07']);
test('sort mutates and returns the array it was given, so copy first', [returnedSame, originalUntouched], [true, 'A12']);
test('with no comparator, sort compares the string forms', asText, [10, 100, 9]);
test('comparing strings with < is code-unit order, which puts capitals first', [codeUnits, withLocale], [['Zainab', 'ada', 'bola'], ['ada', 'bola', 'Zainab']]);
test('a boolean comparator can only say two things, and "before" is not one of them', bothWays, [0, 1]);
test('subtraction only compares numbers; on text it produces NaN', Number.isNaN(onStrings), true);

A comparator is a function of two items returning a sign, and every sorting problem in JavaScript reduces to building the right one. Making it a factory rather than writing the arrow inline is what lets you compose: then(byKey('market'), byKey('takings', 'desc')) reads like the sentence you would say out loud, and adding a third key costs nothing.

The default comparator catches everyone once. [10, 9, 100].sort() gives [10, 100, 9], because with no function the spec converts each element to a string and compares those. There is no numeric mode; you always pass a comparator for numbers.

Three failures worth remembering. a - b is fine for finite numbers and produces NaN for anything else, and a NaN comparator makes the result unspecified rather than throwing. A boolean comparator is worse: true becomes 1 and false becomes 0, so it can say "after" and "same" but never "before", and the sort quietly half-works. And < on strings compares UTF-16 code units, so every capital letter sorts before every lowercase one and accented names land past z; when the order will be read by a person, localeCompare is the comparator you want.

99 a stable sort, and the comparator that contradicts itself

fair

Sort a list of scores by score alone and show that items with equal scores keep the order they came in. Then show that the old decorate-with-the-index trick now produces the identical array, that sorting up and reversing is not the same as sorting down, and that undefined never reaches your comparator at all.

Stuck? Three hints, smallest first
  1. 1. Since ES2019 Array.prototype.sort is required to be stable, so equal elements keep their relative order. Nothing you write makes that happen.
  2. 2. The pre-2019 workaround was to sort { item, i } and use i as the final tiebreak. Run both and compare the results.
  3. 3. reverse flips everything, including the pairs your comparator called equal. A descending comparator does not.

The answer

const byScore = (a, b) => a.score - b.score;
const names = (list) => list.map((e) => e.name);

const entries = [
  { name: 'Chidi', score: 70 },
  { name: 'Amaka', score: 85 },
  { name: 'Bisi', score: 70 },
  { name: 'Tunde', score: 85 },
  { name: 'Ngozi', score: 61 },
];

const ascending = names([...entries].sort(byScore));

// The pre-2019 workaround: carry the original index and use it as the last word.
const decorated = names(
  entries
    .map((item, i) => ({ item, i }))
    .sort((a, b) => byScore(a.item, b.item) || a.i - b.i)
    .map(({ item }) => item)
);
const trickIsRedundant = same(ascending, decorated);

const upThenReversed = names([...entries].sort(byScore).reverse());
const straightDown = names([...entries].sort((a, b) => byScore(b, a)));

// A comparator that never returns 0 claims both orders at once.
const never0 = (a, b) => (a.score < b.score ? -1 : 1);
const contradiction = [never0(entries[0], entries[2]), never0(entries[2], entries[0])];

let sawUndefined = false;
const counting = (a, b) => {
  if (a === undefined || b === undefined) sawUndefined = true;
  return a - b;
};
const withHole = [3, undefined, 1].sort(counting).map((v) => (v === undefined ? 'undefined' : v));

const allEqual = names([...entries].sort(() => 0));

console.log('ascending :', ascending);
console.log('decorated :', decorated, '| redundant:', trickIsRedundant);
console.log('reversed  :', upThenReversed);
console.log('descending:', straightDown);

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('equal scores keep the order they arrived in', ascending, ['Ngozi', 'Chidi', 'Bisi', 'Amaka', 'Tunde']);
test('the decorate-by-index trick gives the identical answer, so it is now dead code', [decorated, trickIsRedundant], [ascending, true]);
test('sorting up and reversing flips the tied pairs as well', upThenReversed, ['Tunde', 'Amaka', 'Bisi', 'Chidi', 'Ngozi']);
test('a descending comparator leaves them alone, so the two disagree', straightDown, ['Amaka', 'Tunde', 'Chidi', 'Bisi', 'Ngozi']);
test('a comparator with no zero says Chidi is after Bisi and Bisi is after Chidi', contradiction, [1, 1]);
test('undefined is moved to the end without the comparator ever seeing it', [withHole, sawUndefined], [[1, 3, 'undefined'], false]);
test('a comparator that always returns 0 is the identity, which is stability restated', allEqual, ['Chidi', 'Amaka', 'Bisi', 'Tunde', 'Ngozi']);

Stability means that when your comparator returns 0, the two items come out in the order they went in. Since ES2019 every engine is required to provide it, which retired a generation of decorate-sort-undecorate helpers: the assertion above shows the trick and the plain sort producing byte-identical output. The practical payoff is that you can sort in stages — by name, then by market — and the earlier sort survives inside each group of the later one.

Stability is a promise the engine keeps only if your comparator returns 0 for equals. (a, b) => a.score < b.score ? -1 : 1 looks harmless and never returns 0, so it asserts a strict order between two equal items, and it asserts opposite things depending on which one it is handed first. That is not just unstable; it is a comparator the spec calls inconsistent, and an inconsistent comparator makes the whole result unspecified. The two-directional assertion is the cheapest test for it.

reverse after an ascending sort is the other thing people reach for. It gives descending order and it also reverses every tie, so the top of your leaderboard changes for no visible reason. Sort descending with a comparator instead. And undefined elements are always moved to the end by the spec, without your comparator ever being called on them, which is why a comparator that "handles undefined" is dead code — and why a hole in your data lands at the bottom of the table rather than where you expected.

100 range, sum and mean over anything iterable

easy

Write range as a generator that takes one, two or three arguments and counts in either direction, then sum and mean that accept any iterable at all: an array, a Set, a Map's values, or the generator itself. Pick sensible answers for the empty case, and make a bad step throw rather than hang.

Stuck? Three hints, smallest first
  1. 1. One argument means "up to, not including", so shift the arguments: if stop is undefined, the single argument was the stop and the start is 0.
  2. 2. A for...of loop is the only thing sum needs, and it is what makes the function work on every iterable rather than just arrays.
  3. 3. mean has to count as it goes, because an iterable has no length. Decide what an empty iterable averages to before you write the division.

The answer

function* range(start, stop, step = 1) {
  if (stop === undefined) {
    stop = start;
    start = 0;
  }
  if (step === 0) throw new RangeError('step must not be 0');
  if (step > 0) for (let i = start; i < stop; i += step) yield i;
  else for (let i = start; i > stop; i += step) yield i;
}

const sum = (iterable) => {
  let total = 0;
  for (const n of iterable) total += n;
  return total;
};

const mean = (iterable) => {
  let total = 0;
  let count = 0;
  for (const n of iterable) {
    total += n;
    count++;
  }
  return count === 0 ? NaN : total / count;
};

const fiveDays = [...range(5)];
const midweek = [...range(2, 6)];
const countdown = [...range(10, 0, -2)];
const wrongWay = [...range(0, 5, -1)];

const takings = [4500, 12000, 9000, 30000];
const asSet = new Set(takings);
const asMap = new Map([['A12', 4500], ['B03', 12000], ['A07', 9000], ['C21', 30000]]);
const fourWays = [sum(takings), sum(asSet), sum(asMap.values()), sum(range(1, 4))];
const average = mean(takings);
const emptySum = sum([]);
const emptyMean = mean([]);
const drift = sum([0.1, 0.2]);

const lazy = range(0, 5, 0);                 // the body has not run yet
let onIteration = 'no error';
try { [...lazy]; } catch (e) { onIteration = e.constructor.name; }

console.log('range   :', fiveDays, midweek, countdown);
console.log('sum     :', fourWays, '| mean:', average);
console.log('empty   :', emptySum, emptyMean);
console.log('step 0  :', typeof lazy.next, '->', onIteration);

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('one argument counts from 0 up to but not including it', fiveDays, [0, 1, 2, 3, 4]);
test('two arguments set both ends, and a negative step counts down', [midweek, countdown], [[2, 3, 4, 5], [10, 8, 6, 4, 2]]);
test('sum takes an array, a Set, a Map iterator or a generator, unchanged', fourWays, [55500, 55500, 55500, 6]);
test('mean has to count as it goes, because an iterable has no length', average, 13875);
test('nothing sums to 0, the identity, but averages to NaN', [emptySum, Number.isNaN(emptyMean)], [0, true]);
test('adding tenths is not exact, so a sum of money should be in kobo', [drift === 0.3, drift.toFixed(2)], [false, '0.30']);
test('a step pointing away from the stop yields nothing instead of looping for ever', wrongWay, []);
test('a step of 0 throws, but only once someone iterates it', [typeof lazy.next, onIteration], ['function', 'RangeError']);

Writing sum against for...of rather than reduce is what makes it work on a Set, a Map's .values(), a string, a generator or anything else with Symbol.iterator. reduce would have tied it to arrays, and [...iterable].reduce(...) would build a throwaway array first. The same argument applies to mean: no length exists, so counting as you go is not a workaround, it is the only way.

The empty cases are a design decision, not an oversight. 0 is the right answer for an empty sum because it is the additive identity: sum([...a, ...b]) === sum(a) + sum(b) stays true. An empty mean has no such answer, so NaN is more honest than 0, which would silently drag a real average down. Note that NaN has to be asserted through Number.isNaN, because JSON.stringify(NaN) is null.

The last assertion is about generators generally. Calling a generator function runs none of its body: it hands back an iterator, and the first next() is what starts executing. So argument validation inside a generator is deferred, sometimes by a long way. If you want range(0, 5, 0) to fail at the call site, wrap it: a plain function that validates and then returns the generator's iterator.

101 a lazy pipeline over an endless sequence

hard

Write generator versions of map and filter, plus take(n, iterable), and run them over a generator that never ends. Take the first four squares of the multiples of three, then count how many source values were actually pulled. Show that an iterator is single-use and that any object can join in by defining Symbol.iterator.

Stuck? Three hints, smallest first
  1. 1. A generator that yields inside a for...of over its input is a lazy step: it does no work until something asks it for a value.
  2. 2. take is the only thing standing between you and a hung tab. It must stop pulling the moment it has enough, so count down and return.
  3. 3. Nothing runs until the consumer asks. Put a counter in a pass-through generator and you can see exactly how far up the chain each request travelled.

The answer

function* naturals() {
  let n = 1;
  while (true) yield n++;   // endless on purpose: only take() decides when to stop
}

function* metered(iterable, meter) {
  for (const value of iterable) {
    meter.count++;
    yield value;
  }
}

function* lazyMap(fn, iterable) {
  for (const value of iterable) yield fn(value);
}

function* lazyFilter(keep, iterable) {
  for (const value of iterable) if (keep(value)) yield value;
}

function* take(n, iterable) {
  if (n <= 0) return;
  let left = n;
  for (const value of iterable) {
    yield value;
    if (--left === 0) return;   // stop PULLING, not just stop yielding
  }
}

const meter = { count: 0 };
const squaresOfThrees = take(4, lazyMap((n) => n * n, lazyFilter((n) => n % 3 === 0, metered(naturals(), meter))));
const firstFour = [...squaresOfThrees];
const pulled = meter.count;

const zeroMeter = { count: 0 };
const nothing = [...take(0, metered(naturals(), zeroMeter))];
const pulledForNothing = zeroMeter.count;

const oneShot = take(3, naturals());
const firstPass = [...oneShot];
const secondPass = [...oneShot];
const freshRun = [...take(3, naturals())];

const shortSource = [...take(10, ['GRP-1', 'GRP-2', 'GRP-3'])];
const eager = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].filter((n) => n % 3 === 0).map((n) => n * n);

const stallRow = {
  from: 3,
  to: 6,
  *[Symbol.iterator]() {
    for (let i = this.from; i <= this.to; i++) yield i;
  },
};
const spread = [...stallRow];

console.log('first four :', firstFour, '| source values pulled:', pulled);
console.log('take(0)    :', nothing, '| pulled:', pulledForNothing);
console.log('one shot   :', firstPass, 'then', secondPass);
console.log('custom     :', spread);

Output


      
8 checks that prove this answer

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

8 checks

test('four answers come out of a source with no end', firstFour, [9, 36, 81, 144]);
test('and exactly twelve source values were pulled to produce them', pulled, 12);
test('the eager version over a guessed range agrees, having done all the work', eager, firstFour);
test('take(0) yields nothing and pulls nothing at all', [nothing, pulledForNothing], [[], 0]);
test('an iterator is single-use: the second pass is empty', [firstPass, secondPass], [[1, 2, 3], []]);
test('the generator function is reusable, though; calling it again starts over', freshRun, [1, 2, 3]);
test('take stops early when the source runs out first', shortSource, ['GRP-1', 'GRP-2', 'GRP-3']);
test('any object with Symbol.iterator can be spread', spread, [3, 4, 5, 6]);

Each of these generators is a pull, not a push. [...take(4, ...)] asks take for a value, which asks lazyMap, which asks lazyFilter, which asks the source. Nothing between them holds an array, so the chain works on a sequence with no end and the peak memory is one value per stage. The counter proves it: twelve numbers were pulled, because the fourth multiple of three is twelve, and the thirteenth was never asked for.

The eager assertion is the comparison worth staring at. [1..12].filter(...).map(...) gives the same answer, but only because someone guessed that twelve was far enough. Guess low and the answer is wrong; guess high and you build two throwaway arrays. Laziness removes the guess.

Two warnings. An iterator is consumed once — the second spread of oneShot is empty, which is why passing a generator to two functions leaves the second one with nothing, and why an iterable object with Symbol.iterator (fresh iterator per call) is the friendlier thing to hand around. And the loop guard in this runner rewrites while and for headers only, so a for...of over naturals() with no take around it hangs with nothing to stop it. Bound every endless sequence at the point of consumption.

102 before and after, two counting wrappers

fair

Write before(n, fn), which calls fn for the first n - 1 calls and then returns the last result it got, and after(n, fn), which stays quiet until the nth call. Show that before(2, fn) is once, that each wrapper carries its own counter, and what the quiet calls actually return.

Stuck? Three hints, smallest first
  1. 1. Both are a closure over a call count. The only difference is which side of the comparison calls the wrapped function.
  2. 2. before has to remember the last result, because it still has to return something once it stops calling through.
  3. 3. Think about what after returns on the calls it swallows, and what that value looks like once it is inside an array being stringified.

The answer

const before = (n, fn) => {
  let calls = 0;
  let last;
  return (...args) => {
    calls++;
    if (calls < n) last = fn(...args);
    return last;
  };
};

const after = (n, fn) => {
  let calls = 0;
  return (...args) => {
    calls++;
    return calls >= n ? fn(...args) : undefined;
  };
};

let discounts = 0;
const applyDiscount = (amount) => {
  discounts++;
  return Math.round(amount * 0.9);
};
const firstTwoOrders = before(3, applyDiscount);
const prices = [firstTwoOrders(10000), firstTwoOrders(20000), firstTwoOrders(30000), firstTwoOrders(40000)];
const discountsRun = discounts;

let alerts = 0;
const raiseAlert = (reason) => {
  alerts++;
  return `alert: ${reason}`;
};
const afterThree = after(3, raiseAlert);
const outcomes = [afterThree('timeout'), afterThree('timeout'), afterThree('declined'), afterThree('bounced')].map((v) => v ?? 'quiet');
const alertsRun = alerts;

let charges = 0;
const charge = (amount) => {
  charges++;
  return amount;
};
const onceLike = before(2, charge);
const onceResults = [onceLike(500), onceLike(900), onceLike(1300)];
const chargesAfterOnce = charges;

const perOrder = before(2, charge);
const perRider = before(2, charge);
const twoWrappers = [perOrder(1), perRider(2)];
const chargesTotal = charges;

const swallowed = after(2, () => 'ran')('first call');
const swallowedShape = [swallowed === undefined, JSON.stringify([swallowed])];

console.log('before(3) :', prices, '| real calls:', discountsRun);
console.log('after(3)  :', outcomes, '| real calls:', alertsRun);
console.log('once      :', onceResults, '| real calls:', chargesAfterOnce);
console.log('swallowed :', swallowedShape);

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('before(3) calls through on the first two calls', prices.slice(0, 2), [9000, 18000]);
test('after that it returns the last result it got, not "no discount"', [prices[2], prices[3], prices[2] === prices[1]], [18000, 18000, true]);
test('so the wrapped function ran exactly twice, and later arguments were thrown away', discountsRun, 2);
test('before(2) is once: one real call, three identical answers', [onceResults, chargesAfterOnce], [[500, 500, 500], 1]);
test('after(3) stays quiet until the third call, then keeps going', outcomes, ['quiet', 'quiet', 'alert: declined', 'alert: bounced']);
test('and it forwards its arguments, so the third call decided the message', alertsRun, 2);
test('each wrapper closes over its own counter, so two wrappers are independent', [twoWrappers, chargesTotal], [[1, 2], 3]);
test('a swallowed call returns undefined, which JSON turns into null', swallowedShape, [true, '[null]']);

These are the two useful relatives of once: before for "only the first few times", after for "not until it has happened enough". once is not a third implementation, it is before(2, fn), and seeing that is the point of the assertion — the off-by-one in the name is the price of the family resemblance.

before caching and returning the last result is the trap. firstTwoOrders(30000) looks like it should discount thirty thousand and instead hands back the discount computed for twenty thousand, so a caller sees a plausible number that is about the wrong order. If a stale answer would be a bug rather than a saving, return a sentinel or throw on the calls you swallow.

after returning undefined for the quiet calls is the honest option, and the last assertion is a reminder about the assertion harness itself: undefined inside an array stringifies to null, so a result that includes swallowed calls has to be projected — with ?? 'quiet' here — before it can be compared.

103 defaults, and a deep merge that keeps the branches

hard

Merge a tenant's partial settings over the shipped defaults so that untouched branches survive. Show what the plain spread destroys, decide what an explicit undefined in the override means, keep arrays replaced rather than concatenated, and then look at what the key __proto__ does to your result.

Stuck? Three hints, smallest first
  1. 1. The spread { ...base, ...override } replaces whole values. That is the bug: a branch mentioned by the override loses every sibling key the override did not repeat.
  2. 2. Recurse only when both sides are plain objects. An array, a Date, a Map or null should be taken whole.
  3. 3. For defaults, spreading lets an explicit undefined win. ??= over the fallback keys is what "fill in the missing ones" actually means.

The answer

const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);

const deepMerge = (base, override) => {
  const out = { ...base };
  for (const [key, value] of Object.entries(override)) {
    if (isPlain(value) && isPlain(out[key])) out[key] = deepMerge(out[key], value);
    else if (value !== undefined) out[key] = value;      // undefined means "no opinion"
  }
  return out;
};

const fillMissing = (target, fallbacks) => {
  const out = { ...target };
  for (const [key, value] of Object.entries(fallbacks)) out[key] ??= value;
  return out;
};

const shipped = {
  currency: 'NGN',
  delivery: { fee: 1500, zones: ['Yaba', 'Ikeja'], window: { open: '09:00', close: '18:00' } },
  retries: 3,
};
const tenant = {
  delivery: { fee: 2000, window: { close: '20:00' } },
  retries: undefined,
};

const merged = deepMerge(shipped, tenant);
const shallow = { ...shipped, ...tenant };
const withZones = deepMerge(shipped, { delivery: { zones: ['Lekki'] } });
const baseFeeStillThere = shipped.delivery.fee;
const branchIsNew = merged.delivery === shipped.delivery;
const zonesAreShared = merged.delivery.zones === shipped.delivery.zones;

const spreadDefaults = { ...{ fee: 1500, currency: 'USD' }, ...{ fee: undefined, currency: 'NGN' } };
const filledDefaults = fillMissing({ fee: undefined, currency: 'NGN' }, { fee: 1500, currency: 'USD' });

const risky = deepMerge({}, JSON.parse('{"__proto__":{"surcharge":9999}}'));
const pollution = [Object.hasOwn(risky, '__proto__'), risky.surcharge, JSON.stringify(risky), ({}).surcharge === undefined];

console.log('merged  :', merged);
console.log('shallow :', shallow);
console.log('defaults:', spreadDefaults, '| filled:', filledDefaults);
console.log('proto   :', pollution);

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 override wins where it speaks, and the rest of the branch survives', merged.delivery, { fee: 2000, zones: ['Yaba', 'Ikeja'], window: { open: '09:00', close: '20:00' } });
test('the plain spread replaces the whole branch, losing zones and the opening time', shallow.delivery, { fee: 2000, window: { close: '20:00' } });
test('an explicit undefined is treated as no opinion here, and as an answer by the spread', [merged.retries, 'retries' in shallow, shallow.retries === undefined], [3, true, true]);
test('arrays are replaced whole, never concatenated', withZones.delivery.zones, ['Lekki']);
test('the base is untouched, and the merged branch is a new object', [baseFeeStillThere, branchIsNew], [1500, false]);
test('but a branch nobody edited is shared with the base, not copied', zonesAreShared, true);
test('spreading defaults lets an undefined overwrite a real value', spreadDefaults, { fee: undefined, currency: 'NGN' });
test('??= fills only what is genuinely missing', filledDefaults, { fee: 1500, currency: 'NGN' });
test('a __proto__ key lands on the prototype: not an own key, invisible to JSON, still readable', pollution, [false, 9999, '{}', true]);

The whole reason a deep merge exists is the second assertion. A partial override is a statement about the keys it mentions, and the spread reads it as a statement about the whole branch, so { delivery: { fee: 2000 } } silently deletes the delivery zones and the opening time. Config bugs of this kind are hard to see because the result is a valid object.

Three choices are being made, and each has a defensible opposite. Treating undefined as "no opinion" means a caller cannot deliberately clear a default — pick a sentinel like null for that. Replacing arrays rather than concatenating is what almost everyone wants, because a merged list of zones with duplicates in it is rarely the intent. And copying with a spread keeps the base intact at the top level while sharing every sub-object the override did not mention: the merged result and the defaults now point at the same zones array, so pushing to it changes both. If that matters, deep clone the base first.

The __proto__ assertion is the security one. Object.entries of a JSON.parse result does include a __proto__ key, and assigning it with out[key] = value runs the inherited setter, which swaps the prototype of the result instead of adding a property. The merged object then answers 9999 for a key it does not own and stringifies to {}, which is about as confusing as a bug gets. Real prototype pollution goes further and reaches Object.prototype itself. The fix is one line: skip __proto__, constructor and prototype, or build the result with Object.create(null).

104 an event emitter with on, off, once and emit

fair

Write an emitter class with a private handler table. on should return an unsubscribe function, once should remove itself after firing, emit should report how many handlers ran, and off should say whether it removed anything. Store handlers in a Set and work out what that does to a duplicate subscription.

Stuck? Three hints, smallest first
  1. 1. A Map from event name to a Set of handlers. The Map keeps event names apart; the Set keeps the handler list unique.
  2. 2. once needs no new bookkeeping: register a wrapper that unsubscribes itself and then calls through.
  3. 3. Iterate a copy of the Set inside emit. Then a handler that subscribes another handler cannot join the emit it is already inside.

The answer

class Emitter {
  #handlers = new Map();

  on(event, handler) {
    if (!this.#handlers.has(event)) this.#handlers.set(event, new Set());
    this.#handlers.get(event).add(handler);
    return () => this.off(event, handler);          // an unsubscribe, not a boolean
  }

  once(event, handler) {
    const wrapped = (...args) => {
      this.off(event, wrapped);
      handler(...args);
    };
    return this.on(event, wrapped);
  }

  off(event, handler) {
    const set = this.#handlers.get(event);
    if (!set) return false;
    const removed = set.delete(handler);
    if (set.size === 0) this.#handlers.delete(event);
    return removed;
  }

  emit(event, ...args) {
    const set = this.#handlers.get(event);
    if (!set) return 0;
    let called = 0;
    for (const handler of [...set]) {               // a copy, so growth waits its turn
      handler(...args);
      called++;
    }
    return called;
  }

  count(event) {
    return this.#handlers.get(event)?.size ?? 0;
  }
}

const log = [];
const bus = new Emitter();
const offReceipt = bus.on('paid', (ref, amount) => log.push(`receipt ${ref} ${amount}`));
bus.on('paid', (ref) => log.push(`ledger ${ref}`));
const firstEmit = bus.emit('paid', 'ORD-771', 4500);

offReceipt();
const secondEmit = bus.emit('paid', 'ORD-772', 900);
const noHandlers = bus.emit('refunded', 'ORD-773');

bus.once('shipped', (ref) => log.push(`sms ${ref}`));
const onceFirst = bus.emit('shipped', 'ORD-771');
const onceSecond = bus.emit('shipped', 'ORD-772');
const shippedLeft = bus.count('shipped');

const audit = (ref) => log.push(`audit ${ref}`);
bus.on('closed', audit);
bus.on('closed', audit);
const closedCount = bus.count('closed');
const closedCalls = bus.emit('closed', 'ORD-771');

const removedUnknown = bus.off('paid', () => {});
const paidLeft = bus.count('paid');

let lateCalls = 0;
const grower = new Emitter();
grower.on('tick', () => grower.on('tick', () => { lateCalls++; }));
const firstTick = grower.emit('tick');
const secondTick = grower.emit('tick');

const shape = [Object.keys(bus).length, JSON.stringify(bus)];

console.log('log     :', log);
console.log('emits   :', firstEmit, secondEmit, noHandlers);
console.log('once    :', onceFirst, onceSecond, '| left:', shippedLeft);
console.log('growth  :', firstTick, secondTick, '| late calls:', lateCalls);

Output


      
8 checks that prove this answer

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

8 checks

test('every handler for the event runs, in the order it was added', log.slice(0, 2), ['receipt ORD-771 4500', 'ledger ORD-771']);
test('emit reports how many ran, and an event nobody listens to is 0', [firstEmit, secondEmit, noHandlers], [2, 1, 0]);
test('the function on() returned took off exactly one handler', log[2], 'ledger ORD-772');
test('once fires and removes itself, leaving nothing behind', [onceFirst, onceSecond, shippedLeft], [1, 0, 0]);
test('a Set means the same function subscribed twice is one subscription', [closedCount, closedCalls], [1, 1]);
test('off with a handler that was never on returns false and disturbs nothing', [removedUnknown, paidLeft], [false, 1]);
test('a handler added during an emit waits for the next one', [firstTick, secondTick, lateCalls], [1, 2, 1]);
test('the handler table is a private field, so the instance projects to nothing', shape, [0, '{}']);

Returning an unsubscribe function from on rather than expecting the caller to keep the handler and pass it back to off is the single best decision in this class. The caller almost never has a name for an inline arrow, so off with the same arrow written out again silently removes nothing — two arrows with identical bodies are different functions. A closure over the exact handler cannot get that wrong.

The Set earns its place and costs something. It makes a duplicate subscription free of charge, which is the behaviour you want when a component might mount twice, and it makes removal cheap. It also means you genuinely cannot register the same function twice on purpose, which occasionally surprises people. once falling out as a four-line wrapper over on and off, with no extra state anywhere, is the payoff for having those two right.

Two details that matter in production. Iterating a copy inside emit makes the handler list for this emit fixed at the start, so a handler that subscribes another handler does not get it called in the same round, and a handler that unsubscribes mid-emit does not disturb the walk. And emit here calls handlers synchronously with no try/catch, so one throwing handler stops the rest — fine for a small internal bus, wrong for anything where subscribers are strangers. Wrap each call, collect the errors, and emit them on an 'error' event.

105 an LRU cache built on Map insertion order

hard

Build a fixed-size cache that evicts the least recently used entry. Exploit the fact that a Map remembers insertion order: deleting a key and setting it again moves it to the newest end. Prove that a read counts as a use, that has does not, and that a cached undefined is indistinguishable from a miss.

Stuck? Three hints, smallest first
  1. 1. A Map iterates oldest-inserted first, so map.keys().next().value is the entry to evict.
  2. 2. To mark a key as freshly used, delete it and set it again. There is no other way to move a key within a Map.
  3. 3. Evict after inserting, not before: check size > max once the new entry is in, so overwriting an existing key never evicts anything.

The answer

class LruCache {
  #max;
  #items = new Map();

  constructor(max) {
    if (!Number.isInteger(max) || max < 1) throw new RangeError('max must be a positive integer');
    this.#max = max;
  }

  get(key) {
    if (!this.#items.has(key)) return undefined;
    const value = this.#items.get(key);
    this.#items.delete(key);            // a read is a use: move it to the newest end
    this.#items.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.#items.has(key)) this.#items.delete(key);
    this.#items.set(key, value);
    if (this.#items.size > this.#max) {
      const oldest = this.#items.keys().next().value;
      this.#items.delete(oldest);
    }
    return this;
  }

  has(key) {
    return this.#items.has(key);        // deliberately NOT a use
  }

  get size() {
    return this.#items.size;
  }

  keys() {
    return [...this.#items.keys()];     // oldest first
  }
}

const quotes = new LruCache(3);
quotes.set('yaba-ikeja', 1500).set('lekki-vi', 2500).set('ojota-ikeja', 1800);
const orderBefore = quotes.keys();

const hit = quotes.get('yaba-ikeja');
const orderAfterRead = quotes.keys();

quotes.set('surulere-yaba', 900);
const orderAfterEvict = quotes.keys();
const evictedRead = quotes.get('lekki-vi') === undefined;
const sizeCapped = quotes.size;

quotes.set('ojota-ikeja', 2000);
const orderAfterUpdate = quotes.keys();
const sizeAfterUpdate = quotes.size;

const present = quotes.has('yaba-ikeja');
const orderAfterHas = quotes.keys();

quotes.set('kano-kaduna', undefined);
const holdsUndefined = [quotes.has('kano-kaduna'), quotes.get('kano-kaduna') === undefined, quotes.has('nowhere')];
const finalKeys = quotes.keys();

let badMax = 'no error';
try { new LruCache(0); } catch (e) { badMax = e.constructor.name; }

console.log('inserted :', orderBefore);
console.log('after read:', orderAfterRead, '| hit:', hit);
console.log('evicted  :', orderAfterEvict, '| size:', sizeCapped);
console.log('final    :', finalKeys, '| bad max:', badMax);

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('keys come out oldest first, which is insertion order', orderBefore, ['yaba-ikeja', 'lekki-vi', 'ojota-ikeja']);
test('a read is a use, so the key it touched moves to the newest end', [hit, orderAfterRead], [1500, ['lekki-vi', 'ojota-ikeja', 'yaba-ikeja']]);
test('the next write evicts the oldest, which is now the one nobody read', [orderAfterEvict, evictedRead], [['ojota-ikeja', 'yaba-ikeja', 'surulere-yaba'], true]);
test('so the size never passes the limit', sizeCapped, 3);
test('overwriting an existing key refreshes it without growing anything', [orderAfterUpdate, sizeAfterUpdate], [['yaba-ikeja', 'surulere-yaba', 'ojota-ikeja'], 3]);
test('has answers without counting as a use, so the order is unchanged', [present, orderAfterHas], [true, ['yaba-ikeja', 'surulere-yaba', 'ojota-ikeja']]);
test('a cached undefined reads exactly like a miss, which is why has exists', holdsUndefined, [true, true, false]);
test('and the newest three are what is left, the first key having aged out', finalKeys, ['surulere-yaba', 'ojota-ikeja', 'kano-kaduna']);
test('a limit below one throws instead of building a cache that cannot hold anything', badMax, 'RangeError');

The trick is that Map guarantees insertion order for every key type, and there is no operation to move a key within it. So "mark as recently used" becomes delete-then-set, and "least recently used" becomes the first key the iterator hands you. That is a real LRU in about twenty lines, with every operation O(1), and no linked list to maintain — the Map already is one.

Two decisions to be deliberate about. Evicting after the insert, on size > max, is what makes overwriting an existing key harmless; check before, on size >= max, and updating a key you already hold throws away an unrelated entry. And has not counting as a use is a choice: it makes has safe for diagnostics and cache-inspection code, and it means a caller who checks has then calls get touches recency exactly once.

The undefined assertion is the one that bites. get returns undefined both for a miss and for a key genuinely holding undefined, so a cache used to memoise a function that can return undefined recomputes for ever unless the caller asks has first. The alternatives are to store a wrapper such as { value }, or to return a sentinel object for a miss. Map itself has the same shape of problem, which is why has exists there too.

106 an immutable update that shares what it did not change

fair

Write setIn(state, path, value) that returns a new state with one deep value replaced, and updateIn(state, path, fn) built on top of it. Copy only the objects along the path, share everything else, and show that it works on a frozen object where a plain assignment throws.

Stuck? Three hints, smallest first
  1. 1. Recurse on the head of the path: { ...target, [head]: setIn(target[head], rest, value) }. The base case is an empty path.
  2. 2. An array on the path has to come back an array, so branch on Array.isArray and copy with a spread rather than object-spreading it.
  3. 3. Check identity, not contents, to see what was copied: next.cart === state.cart is the assertion that proves you shared the untouched branch.

The answer

const segments = (path) => (Array.isArray(path) ? path : String(path).split('.'));

const setIn = (target, path, value) => {
  const [head, ...rest] = segments(path);
  if (head === undefined) return value;                       // the base case: replace it all
  const nested = rest.length ? setIn(target?.[head] ?? {}, rest, value) : value;
  if (Array.isArray(target)) {
    const copy = [...target];
    copy[Number(head)] = nested;
    return copy;
  }
  return { ...target, [head]: nested };
};

const getIn = (target, path) => segments(path).reduce((acc, key) => acc?.[key], target);
const updateIn = (target, path, fn) => setIn(target, path, fn(getIn(target, path)));

const state = {
  user: { name: 'Amaka', address: { lga: 'Yaba', city: 'Lagos' } },
  cart: { items: [{ sku: 'GRP-1', units: 2 }, { sku: 'GRP-2', units: 1 }], note: 'gate 3' },
};

const moved = setIn(state, 'user.address.lga', 'Surulere');
const pathIsNew = [moved !== state, moved.user !== state.user, moved.user.address !== state.user.address];
const restIsShared = [moved.cart === state.cart, moved.user.name === state.user.name];

const restocked = setIn(state, 'cart.items.0.units', 5);
const arrayShape = [Array.isArray(restocked.cart.items), restocked.cart.items[0].units, state.cart.items[0].units];
const otherItemShared = restocked.cart.items[1] === state.cart.items[1];

const bumped = updateIn(state, 'cart.items.0.units', (n) => n + 10);
const created = setIn(state, 'user.bank.name', 'GTBank');

const frozen = Object.freeze({ vendor: { name: 'Ngozi Traders' }, tier: 1 });
const promoted = setIn(frozen, 'tier', 2);
let writeError = 'no error';
try { frozen.tier = 2; } catch (e) { writeError = e.constructor.name; }

const wholesale = setIn(state, [], 'replaced entirely');

console.log('moved  :', moved.user.address, '| original:', state.user.address);
console.log('shared :', restIsShared, '| path copied:', pathIsNew);
console.log('array  :', arrayShape, '| sibling shared:', otherItemShared);
console.log('frozen :', promoted.tier, frozen.tier, writeError);

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 new state holds the new value and the old one is unchanged', [moved.user.address.lga, state.user.address.lga], ['Surulere', 'Yaba']);
test('every object along the path is a new object', pathIsNew, [true, true, true]);
test('and everything off the path is the very same object, not a copy', restIsShared, [true, true]);
test('an array on the path comes back an array, with one item replaced', arrayShape, [true, 5, 2]);
test('its siblings are shared, so a big list costs one new array and one new item', otherItemShared, true);
test('updateIn reads the old value first and writes what the function returns', [bumped.cart.items[0].units, state.cart.items[0].units], [12, 2]);
test('a path through nothing builds the objects it needs', [created.user.bank.name, Object.keys(created.user)], ['GTBank', ['name', 'address', 'bank']]);
test('it works on a frozen object, since it never writes to one, while the assignment throws', [promoted.tier, frozen.tier, writeError], [2, 1, 'TypeError']);
test('an empty path replaces the whole thing, which is the recursion base case', wholesale, 'replaced entirely');

This is structural sharing, the idea every immutable-state library is built on. The new state is not a copy of the old one: it is a new object per level along the path, pointing at the same objects everywhere else. Replacing one item in a thousand-item cart allocates one array and one object, not a thousand.

The identity assertions are the ones to internalise, because they are what makes change detection cheap. A component holding the old state.cart can compare it by reference to the new one and skip re-rendering in a single ===, with no deep walk. That is also why mutating in place breaks these libraries: the reference stays equal, so nothing notices.

Two costs. setIn will happily build a branch out of a typo in the path, so a wrong path is an unnoticed new key rather than an error. And it never writes, which is why it works on a frozen object while frozen.tier = 2 throws — in strict mode, which is how these panels run. Outside strict mode that same assignment would fail silently, which is the real argument for strict mode.

Newsletter

New writing, when it lands.

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