Emmanuel Akinfulubi

JavaScript: Fundamental (ES6), part 1, 41–76

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

Exercises 41 to 76 of 150

41 A class, its constructor and where its methods live

easy

Write a class with a constructor and one method, build two instances, and prove that the data sits on each instance while the method sits somewhere both of them share.

Stuck? Three hints, smallest first
  1. 1. Whatever the constructor assigns to this becomes an own property of that one instance.
  2. 2. A method written in the class body is installed on ClassName.prototype, not on the instance.
  3. 3. Object.keys and JSON.stringify both list own enumerable properties only.

The answer

class Stall {
  constructor(owner, area) {
    this.owner = owner;
    this.area = area;
  }
  label() {
    return `${this.owner} of ${this.area}`;
  }
}

const oyin = new Stall('oyin', 'Balogun');
const bola = new Stall('bola', 'Yaba');

const ownData = Object.keys(oyin);
const methodIsOwn = Object.prototype.hasOwnProperty.call(oyin, 'label');
const methodIsShared = Object.prototype.hasOwnProperty.call(Stall.prototype, 'label');
const sameFunction = oyin.label === bola.label;
const enumerable = Object.getOwnPropertyDescriptor(Stall.prototype, 'label').enumerable;
const seenByForIn = [];
for (const key in oyin) seenByForIn.push(key);

console.log('instance   :', oyin);
console.log('label()    :', oyin.label());
console.log('own keys   :', ownData, '| label is own?', methodIsOwn);
console.log('on prototype:', methodIsShared, '| one function for both instances?', sameFunction);
console.log('serialised :', JSON.stringify(oyin));

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 constructor put two own properties on the instance', ownData, ['owner', 'area']);
test('the method calls with this bound to the receiver', oyin.label(), 'oyin of Balogun');
test('the method is not an own property of the instance', methodIsOwn, false);
test('it is an own property of the prototype', methodIsShared, true);
test('so both instances hold the very same function object', sameFunction, true);
test('class methods are not enumerable', enumerable, false);
test('which is why for...in over the instance sees only data', seenByForIn, ['owner', 'area']);
test('and only that data serialises', JSON.stringify(oyin), '{"owner":"oyin","area":"Balogun"}');
test('the instance still knows which class built it', oyin.constructor.name, 'Stall');

The saving is the fifth assertion. A thousand stalls share one label function, because the method is on the prototype and the instance only holds data. Write the same thing as an object literal with a method inside a factory and you get a thousand function objects.

The trap is the sixth. Class methods are deliberately non-enumerable, unlike a method you assign into an object literal, so for...in, Object.keys and JSON.stringify all skip them. That is usually a relief — no behaviour leaking into your JSON — but it also means a quick Object.keys(instance) tells you nothing about what the instance can do.

42 Getters and setters in a class

fair

Give a cart a total that is computed rather than stored, a formatted naira string beside it, and a discountPercent setter that refuses a value outside 0 to 100. Then show what happens when you assign to a getter that has no setter.

Stuck? Three hints, smallest first
  1. 1. get total() { ... } is read as a property, cart.total, with no call parentheses.
  2. 2. A setter is the place for validation, because there is no other way in.
  3. 3. These panels run in strict mode, so writing to an accessor with no setter throws instead of failing quietly.

The answer

class Cart {
  constructor() {
    this.items = [];
  }
  add(name, kobo) {
    this.items.push({ name, kobo });
    return this;                              // so calls chain
  }
  get total() {
    return this.items.reduce((sum, item) => sum + item.kobo, 0);
  }
  get naira() {
    return `₦${(this.total / 100).toFixed(2)}`;
  }
  set discountPercent(pct) {
    if (typeof pct !== 'number' || pct < 0 || pct > 100) {
      throw new RangeError(`discount ${pct} is not a percentage`);
    }
    this._discount = pct;
  }
  get payable() {
    return Math.round((this.total * (100 - (this._discount ?? 0))) / 100);
  }
}

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

const cart = new Cart().add('gari', 250000).add('palm oil', 480000);
const beforeDiscount = cart.total;
const formatted = cart.naira;
const storedKeys = Object.keys(cart);
const onPrototype = Object.prototype.hasOwnProperty.call(Cart.prototype, 'total');
const descriptor = Object.getOwnPropertyDescriptor(Cart.prototype, 'total');
const refused = attempt(() => { cart.discountPercent = 120; });
cart.discountPercent = 10;
const payable = cart.payable;
const writeToGetter = attempt(() => { cart.total = 0; });
const serialised = JSON.parse(JSON.stringify(cart));

console.log('total   :', beforeDiscount, 'kobo | shown as', formatted);
console.log('stored  :', storedKeys, '| total on prototype?', onPrototype);
console.log('120%    :', refused, '| 10% payable:', payable);
console.log('cart.total = 0 ->', writeToGetter);
console.log('serialised:', serialised);

Output


      
9 checks that prove this answer

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

9 checks

test('the getter computes from the items instead of storing a total', beforeDiscount, 730000);
test('a second getter can format the same data', formatted, '₦7300.00');
test('nothing called total is stored on the instance', storedKeys, ['items']);
test('the accessor lives on the prototype, like a method', onPrototype, true);
test('and it is a get with no set', [typeof descriptor.get, descriptor.set === undefined], ['function', true]);
test('the setter validates, so 120 percent is refused', refused, 'RangeError');
test('a valid discount comes off the computed total', payable, 657000);
test('assigning to a getter-only property throws in strict mode', writeToGetter, 'TypeError');
test('getters do not serialise; only what is stored does', Object.keys(serialised), ['items', '_discount']);

A getter turns a derived value into a property, so callers cannot desynchronise it: there is no total to forget to update when an item is added. The setter is the only door into _discount, which is what makes validating there worthwhile.

Two traps. First, the accessor is on the prototype, so JSON.stringify ignores it — send a cart over the wire and the total vanishes, because JSON carries own enumerable data only. Second, a getter with no setter is not merely ignored on write: in strict mode, and every class body and every panel here is strict, cart.total = 0 throws a TypeError. Outside strict mode the same line fails in silence, which is how this bug survives for months.

43 Static members, and a static block

fair

Build a fare table on the class itself: a static field for the base fare, a private static Map of area multipliers filled by a static block, and a static method that prices a trip. Then subclass it with a different base fare and see which one the inherited static method uses.

Stuck? Three hints, smallest first
  1. 1. static members hang off the class, not off instances, so Fare.base works and ride.base does not.
  2. 2. A static { ... } block runs once, when the class is defined, and can reach private static fields.
  3. 3. Inside a static method this is the class the call was made on, which for a subclass is the subclass.

The answer

class Fare {
  static base = 25000;                        // kobo
  static #rates = new Map();

  static {                                    // runs once, at definition time
    Fare.#rates.set('Ikeja', 1);
    Fare.#rates.set('Lekki', 2.5);
    Fare.#rates.set('Yaba', 1.5);
  }

  static price(area) {
    if (!Fare.#rates.has(area)) throw new RangeError(`no rate for ${area}`);
    return Math.round(this.base * Fare.#rates.get(area));   // this = the class called on
  }
  static rateViaThis(area) {
    return this.#rates.get(area);             // only works when this really is Fare
  }
  static get areas() {
    return [...Fare.#rates.keys()];
  }
  constructor(area) {
    this.area = area;
    this.kobo = Fare.price(area);
  }
}

class NightFare extends Fare {
  static base = 40000;                        // same table, dearer base
}

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

const ride = new Fare('Ikeja');
const areasKnownBeforeAnyInstance = Fare.areas;
const dayLekki = Fare.price('Lekki');
const nightIkeja = NightFare.price('Ikeja');
const unknown = attempt(() => Fare.price('Epe'));
const privateViaFare = Fare.rateViaThis('Ikeja');
const privateViaSubclass = attempt(() => NightFare.rateViaThis('Ikeja'));

console.log('areas    :', areasKnownBeforeAnyInstance);
console.log('day Lekki:', dayLekki, '| night Ikeja:', nightIkeja);
console.log('instance :', ride, '| ride.base:', ride.base);
console.log('unknown area     :', unknown);
console.log('#rates via Fare  :', privateViaFare, '| via NightFare:', privateViaSubclass);

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 static block filled the table before any instance existed', areasKnownBeforeAnyInstance, ['Ikeja', 'Lekki', 'Yaba']);
test('a static method prices a trip from the static field', dayLekki, 62500);
test('a static is on the class, not on instances', [ride.base === undefined, Fare.base], [true, 25000]);
test('the instance still reached it through the class name', ride.kobo, 25000);
test('this in a static method is the class it was called on, so the subclass base wins', nightIkeja, 40000);
test('an area the table does not know throws', unknown, 'RangeError');
test('a private static is readable through this when this is the declaring class', privateViaFare, 1);
test('but the subclass has no such private field, so this.#rates throws', privateViaSubclass, 'TypeError');
test('statics are inherited by the subclass as a class, not copied', NightFare.areas, ['Ikeja', 'Lekki', 'Yaba']);

The static block exists because a static field initialiser is a single expression: anything needing several statements, or a loop, or a try, had to be done after the class body where it reads as an afterthought. The block runs once at definition, in order with the static fields, with this bound to the class, and it is the only place outside a method that can touch a private static.

Two behaviours worth separating. this.base inside a static method is late-bound, so NightFare.price silently prices at the subclass's base — usually exactly what you want, and a shock if you assumed a static was fixed at 25000. this.#rates is the opposite: private names are resolved against the declaring class, and NightFare is not it, so the same this that helpfully became the subclass now makes the lookup a TypeError. Name the class explicitly when reaching for a private static.

44 Private # fields and # methods

fair

Give an account a private balance, a private method that writes an audit line, and a private static counter. Then prove that reaching for #kobo from outside the class is not undefined but a syntax error, and use #kobo in value as a brand check.

Stuck? Three hints, smallest first
  1. 1. A # name is only in scope inside the class body. Outside it the parser rejects the code, so the failure has to be produced with new Function and caught.
  2. 2. Private methods work the same way as private fields, and neither appears on the prototype.
  3. 3. #kobo in value asks "was this object built by this class?" and returns false instead of throwing.

The answer

class Account {
  #kobo = 0;
  #log = [];
  static #opened = 0;

  constructor(owner) {
    this.owner = owner;
    Account.#opened++;
  }
  deposit(kobo) {
    this.#record('in', kobo);
    this.#kobo += kobo;
    return this;
  }
  get balance() {
    return this.#kobo;
  }
  get history() {
    return [...this.#log];                    // a copy, so callers cannot edit it
  }
  #record(kind, kobo) {                       // private method
    this.#log.push(`${kind}:${kobo}`);
  }
  static has(value) {
    return #kobo in value;                    // brand check, never throws
  }
  static get opened() {
    return Account.#opened;
  }
}

const parses = (src) => { try { new Function(src); return 'parsed'; } catch (e) { return e.constructor.name; } };

const tunde = new Account('tunde').deposit(500000);
const ada = new Account('ada');
const outsideAccess = parses('class A { #x = 1; } const a = new A(); return a.#x;');
const insideAccess = parses('class A { #x = 1; read() { return this.#x; } } return new A().read();');
const impostor = { owner: 'tunde', balance: 500000 };

console.log('balance  :', tunde.balance, '| history:', tunde.history);
console.log('own keys :', Object.keys(tunde), '| JSON:', JSON.stringify(tunde));
console.log('a.#x outside the class parses?', outsideAccess, '| inside?', insideAccess);
console.log('brand    :', Account.has(tunde), Account.has(impostor));
console.log('opened   :', Account.opened);

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 deposit landed in the private field', tunde.balance, 500000);
test('which is not an own key', Object.keys(tunde), ['owner']);
test('and does not serialise', JSON.stringify(tunde), '{"owner":"tunde"}');
test('reading #kobo from outside is a syntax error, not undefined', outsideAccess, 'SyntaxError');
test('the same access inside the body is fine', insideAccess, 'parsed');
test('a private method is not on the prototype either', Object.getOwnPropertyNames(Account.prototype), ['constructor', 'deposit', 'balance', 'history']);
test('but it ran, and wrote the audit line', tunde.history, ['in:500000']);
test('#kobo in x answers the brand question without throwing', [Account.has(tunde), Account.has(impostor)], [true, false]);
test('a private static counted both constructions, and the second account has its own balance', [Account.opened, ada.balance], [2, 0]);

_name was always a request. #name is enforced by the parser: the fourth assertion shows the failure arriving before the program runs, which is why no amount of Object.keys, JSON.parse(JSON.stringify(...)) or Reflect.ownKeys will find it. That is stronger than the old WeakMap-keyed-by-instance trick and far cheaper.

The brand check is the part people miss. value.#kobo on a foreign object throws a TypeError, so it is useless for asking "is this one of mine?" — but #kobo in value answers false quietly. It is a truer test than instanceof, which an Object.create(Account.prototype) would pass while having no private field at all.

45 extends, super and an overridden method

fair

Extend a vehicle class, call super() in the child constructor, and override a method so that it wraps the parent's version with super.describe() rather than replacing it. Then find out what happens if the child constructor touches this before calling super().

Stuck? Three hints, smallest first
  1. 1. A derived constructor must call super() before it may read or write this.
  2. 2. super.method() calls the parent version with the same this. Calling this.method() instead would recurse for ever.
  3. 3. A subclass inherits statics as well, because the class object itself has the parent class as its prototype.

The answer

class Vehicle {
  static fleet = 'Lagos';
  constructor(plate) {
    this.plate = plate;
    this.trips = 0;
  }
  describe() {
    return `vehicle ${this.plate}`;
  }
  run(km) {
    this.trips++;
    return km;
  }
}

class Danfo extends Vehicle {
  constructor(plate, seats) {
    super(plate);                             // parent first
    this.seats = seats;
  }
  describe() {
    return `danfo ${super.describe()} with ${this.seats} seats`;
  }
  run(km) {
    return super.run(km) * this.seats;        // parent counts, child scales
  }
}

const bus = new Danfo('LSD-244-XA', 14);
const scaled = bus.run(10);

const tooEarly = (() => {
  class Broken extends Vehicle {
    constructor() {
      this.plate = 'set before super';        // illegal
      super('never reached');
    }
  }
  try { new Broken(); return 'built'; } catch (e) { return e.constructor.name; }
})();

console.log('keys     :', Object.keys(bus));
console.log('describe :', bus.describe());
console.log('run(10)  :', scaled, '| trips now', bus.trips);
console.log('statics  :', Danfo.fleet, '| Danfo.__proto__ === Vehicle:', Object.getPrototypeOf(Danfo) === Vehicle);
console.log('this before super():', tooEarly);

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('super() runs the parent constructor first, so its keys come first', Object.keys(bus), ['plate', 'trips', 'seats']);
test('the override wraps the parent rather than replacing it', bus.describe(), 'danfo vehicle LSD-244-XA with 14 seats');
test('super.run does the parent work and the child scales the result', scaled, 140);
test('the parent still counted the trip', bus.trips, 1);
test('the instance is both classes at once', [bus instanceof Danfo, bus instanceof Vehicle], [true, true]);
test('the parent prototype is in the instance chain', Object.getPrototypeOf(Danfo.prototype) === Vehicle.prototype, true);
test('and the parent class is in the class chain, which is how statics inherit', [Danfo.fleet, Object.getPrototypeOf(Danfo) === Vehicle], ['Lagos', true]);
test('touching this before super() throws a ReferenceError', tooEarly, 'ReferenceError');

extends wires up two chains, not one: Danfo.prototype inherits from Vehicle.prototype so instances find inherited methods, and Danfo itself inherits from Vehicle so Danfo.fleet resolves. The second chain is the part people are surprised by, and it is why a static method's this can be a subclass.

The last assertion is not a style rule. In a derived constructor this does not exist until super() returns — the parent is what creates the object — so an early write is a ReferenceError, in the same family as reading a let before its declaration. Note also that super.describe() is resolved against the prototype where the method was written, not against this; that is what stops describe calling itself for ever.

46 A class field is assigned after super() returns

hard

A parent constructor calls a method the child overrides, and the child also declares a class field of the same name. Work out which value survives, then get the behaviour you wanted by assigning in the constructor instead. Finally show that fields initialise top to bottom.

Stuck? Three hints, smallest first
  1. 1. A child field initialiser runs immediately after super() returns — that is, after everything the parent constructor did.
  2. 2. So a field will overwrite whatever the parent assigned to the same property name.
  3. 3. Field initialisers run in source order, and each one can read only the fields above it.

The answer

const calls = [];

class Base {
  constructor() {
    calls.push('Base constructor');
    this.setUp();                             // calls the child's override
  }
  setUp() {
    this.ready = 'base';
  }
}

class WithField extends Base {
  ready = 'field';                            // runs AFTER super() returns
  setUp() {
    calls.push('override ran');
    this.ready = 'override';
  }
}

class WithAssignment extends Base {
  constructor() {
    super();                                  // override's value is left alone
    calls.push('after super');
  }
  setUp() {
    this.ready = 'override';
  }
}

const surprising = new WithField();
const expected = new WithAssignment();

class Rate {
  constructor() { this.kobo = 25000; }
}
class Doubled extends Rate {
  twice = this.kobo * 2;                      // parent already ran, so this works
}

class Forward {
  first = 1;
  second = this.first + 1;
}
class Backward {
  second = this.first + 1;                    // first is still undefined here
  first = 1;
}

class Both {
  field = () => 'arrow';
  method() { return 'method'; }
}
const both = new Both();

console.log('call order    :', calls);
console.log('with a field  :', surprising.ready, '<- the override was overwritten');
console.log('with an assign:', expected.ready);
console.log('field reading the parent:', new Doubled().twice);
console.log('top to bottom :', new Forward().second, '| bottom to top:', new Backward().second);
console.log('own keys of Both:', Object.keys(both));

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 parent constructor did call the child override', calls.slice(0, 2), ['Base constructor', 'override ran']);
test('but the child field ran afterwards and overwrote it', surprising.ready, 'field');
test('a child with no field of that name keeps the override', expected.ready, 'override');
test('a field can read what the parent set, because super() has already returned', new Doubled().twice, 50000);
test('fields initialise in source order', new Forward().second, 2);
test('so a field cannot read one declared below it', Number.isNaN(new Backward().second), true);
test('a field is an own property of every instance; a method is not', Object.keys(both), ['field']);
test('so two instances get two different function objects for the field', new Both().field === new Both().field, false);
test('while the method is one shared function', new Both().method === both.method, true);

This is the initialise-in-the-parent trap, and it costs people an afternoon. The order is: parent constructor runs to completion, then the child's field initialisers, then the rest of the child constructor. So a child field always wins over anything the parent assigned to the same name, including a value the parent computed by calling a method the child overrode. If you need the parent's work to stand, assign in the constructor after super() instead of declaring a field — or better, do not call overridable methods from a constructor at all.

The Backward class shows the same ordering rule inside one class: field initialisers are statements that run in sequence, so reading a field declared below gives undefined, and undefined + 1 is NaN rather than an error. The last pair is a cost, not a bug: a field holding an arrow is per-instance, so a thousand instances hold a thousand functions.

47 instanceof and the chain it walks

fair

Show that instanceof asks about the prototype chain and nothing else: it says yes for an object new never touched, changes its answer when you swap a prototype, says no for a primitive, and throws if the right-hand side is not callable. Then override it with Symbol.hasInstance.

Stuck? Three hints, smallest first
  1. 1. a instanceof B walks the chain from a looking for B.prototype.
  2. 2. Object.create and Object.setPrototypeOf can put an object in or out of a chain without any constructor running.
  3. 3. A class can define static [Symbol.hasInstance](value) and answer the question itself.

The answer

class Market {}
class OpenMarket extends Market {}

class Even {
  static [Symbol.hasInstance](value) {
    return Number.isInteger(value) && value % 2 === 0;
  }
}

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

const shop = new OpenMarket();
const upTheChain = [shop instanceof OpenMarket, shop instanceof Market, shop instanceof Object];
const notDownIt = new Market() instanceof OpenMarket;

const neverConstructed = Object.create(OpenMarket.prototype);
const countsAnyway = neverConstructed instanceof OpenMarket;

const demoted = new OpenMarket();
Object.setPrototypeOf(demoted, Market.prototype);
const afterSwap = [demoted instanceof OpenMarket, demoted instanceof Market];

const primitives = ['text' instanceof String, new String('text') instanceof String];
const arrays = [[] instanceof Array, Array.isArray([])];
const notCallable = attempt(() => shop instanceof { prototype: Market.prototype });

console.log('up the chain :', upTheChain, '| down it:', notDownIt);
console.log('Object.create:', countsAnyway, '| after setPrototypeOf:', afterSwap);
console.log('primitives   :', primitives, '| arrays:', arrays);
console.log('right side not callable:', notCallable);
console.log('hasInstance  :', 4 instanceof Even, 5 instanceof Even);

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('instanceof is true for the class and every ancestor', upTheChain, [true, true, true]);
test('but not for a descendant', notDownIt, false);
test('an object new never touched passes, because only the chain is checked', countsAnyway, true);
test('and swapping the prototype changes the answer for an object new did build', afterSwap, [false, true]);
test('a primitive string is not an instance of String; a wrapper object is', primitives, [false, true]);
test('an array passes instanceof Array, and Array.isArray agrees here', arrays, [true, true]);
test('the right-hand side must be callable, prototype or no prototype', notCallable, 'TypeError');
test('Symbol.hasInstance replaces the check entirely', [4 instanceof Even, 5 instanceof Even], [true, false]);
test('and it does not need an object at all', [2.5 instanceof Even, 'x' instanceof Even], [false, false]);

instanceof reads like "was this made by that class" and means "is that class's prototype somewhere in this object's chain". The third and fourth assertions separate the two readings: an Object.create product that no constructor ever ran for passes, and a genuine instance stops passing the moment its prototype is swapped. For a class with private fields, #field in value from exercise 44 is the honest brand check.

Array.isArray exists for the case this exercise cannot show: an array from another realm — an iframe, a worker, a vm context — has that realm's Array.prototype in its chain, so arr instanceof Array is false while Array.isArray(arr) is true. Prefer Array.isArray always. And note the right-hand side must be callable: an object that merely has a prototype property throws.

48 A class is not hoisted

fair

Use a class above its declaration and use a function above its declaration, and report the difference. Include typeof on the class name, an extends clause naming a class declared later, and an attempt to reassign the class name from inside the body.

Stuck? Three hints, smallest first
  1. 1. A class declaration is hoisted into scope but left uninitialised, exactly like let. A function declaration is hoisted complete.
  2. 2. typeof is safe on a name nobody declared, and not safe on one that is declared but still in its dead zone.
  3. 3. The extends expression is evaluated when the class declaration is reached, not later.

The answer

function classFirst() {
  let out;
  try { out = new Kiosk('A').tag; } catch (e) { out = e.constructor.name; }
  class Kiosk {
    constructor(id) { this.tag = 'kiosk-' + id; }
  }
  return out;
}

function functionFirst() {
  const made = kiosk('A');                    // fine: hoisted whole
  function kiosk(id) { return 'kiosk-' + id; }
  return made;
}

function typeofBefore() {
  let seen;
  try { seen = typeof Kiosk; } catch (e) { seen = e.constructor.name; }
  class Kiosk {}
  return seen;
}

function extendsLater() {
  let out;
  try { class Child extends Parent {} out = 'declared'; } catch (e) { out = e.constructor.name; }
  class Parent {}
  return out;
}

class Fixed {
  static rename() {
    try { Fixed = 1; return 'changed'; } catch (e) { return e.constructor.name; }
  }
}

const Anon = class {
  kind = 'anon';
};

class Kiosk {
  constructor(id) { this.tag = 'kiosk-' + id; }
}
const afterDeclaration = new Kiosk('B').tag;

console.log('class used first   :', classFirst());
console.log('function used first:', functionFirst());
console.log('typeof in the dead zone:', typeofBefore(), '| typeof undeclared:', typeof NoSuchClass);
console.log('extends a later class  :', extendsLater());
console.log('after the declaration  :', afterDeclaration);
console.log('rename from inside     :', Fixed.rename(), '| expression class name:', Anon.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('a class cannot be used above its declaration', classFirst(), 'ReferenceError');
test('a function can, because the whole declaration is hoisted', functionFirst(), 'kiosk-A');
test('even typeof throws inside the dead zone', typeofBefore(), 'ReferenceError');
test('while typeof a name nobody declared is quietly fine', typeof NoSuchClass, 'undefined');
test('extends is evaluated at the declaration, so a parent declared later throws', extendsLater(), 'ReferenceError');
test('below the declaration everything works', afterDeclaration, 'kiosk-B');
test('the class name is a constant binding inside its own body', Fixed.rename(), 'TypeError');
test('a class expression is a value, and takes its name from what it is assigned to', [Anon.name, new Anon().kind], ['Anon', 'anon']);

"Classes are not hoisted" is shorthand and slightly wrong. The binding is hoisted — that is why typeof Kiosk throws rather than reporting 'undefined' — but it is uninitialised until execution reaches the declaration, exactly the temporal dead zone from exercise 2. A function declaration is hoisted with its body attached, which is why it can be called from above.

Two consequences. Ordering matters for extends: the parent expression is evaluated eagerly, so a file that declares subclasses before their bases throws at load. And inside the body the class name is its own immutable binding, so Fixed = 1 in a method is a TypeError while the same assignment from outside merely rebinds the outer name. The Anon.name assertion is the small pleasant surprise: an anonymous class expression infers its name from the variable it is assigned to.

49 new.target, and refusing to run without new

fair

Write a constructor function that notices it was called without new and fixes it up. Then write an abstract base class whose constructor throws when it is the direct target, and prove a subclass passes straight through it.

Stuck? Three hints, smallest first
  1. 1. new.target is the constructor that new was applied to, or undefined in a plain call.
  2. 2. In a base constructor reached through super(), new.target is the subclass.
  3. 3. A class constructor cannot be called without new at all, so the fix-up trick only applies to plain functions.

The answer

const seen = [];

function Fee(kobo) {
  seen.push(new.target === undefined ? 'no new' : new.target.name);
  if (!new.target) return new Fee(kobo);      // called flat: put it right
  this.kobo = kobo;
}

class Levy {
  constructor(kobo) { this.kobo = kobo; }
}

class Payment {
  static built = [];
  constructor(kobo) {
    if (new.target === Payment) throw new TypeError('Payment is abstract');
    Payment.built.push(new.target.name);
    this.kobo = kobo;
  }
}
class Transfer extends Payment {}

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

const withNew = new Fee(500);
const withoutNew = Fee(500);
const calledFlat = attempt(() => Levy(500));
const abstractDirect = attempt(() => new Payment(100));
const transfer = new Transfer(750);

console.log('new.target seen  :', seen);
console.log('with new         :', withNew, '| without new:', withoutNew);
console.log('class called flat:', calledFlat);
console.log('new Payment()    :', abstractDirect, '| new Transfer():', transfer);
console.log('base saw target  :', Payment.built);

Output


      
8 checks that prove this answer

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

8 checks

test('with new, new.target is the function itself; without it, undefined', seen, ['Fee', 'no new', 'Fee']);
test('the fix-up call still produced a real instance', [withoutNew instanceof Fee, withoutNew.kobo], [true, 500]);
test('and it has the same own keys as the proper call', [Object.keys(withoutNew), Object.keys(withNew)], [['kobo'], ['kobo']]);
test('a class constructor refuses a call without new outright', calledFlat, 'TypeError');
test('an abstract base refuses to be the direct target', abstractDirect, 'TypeError');
test('but a subclass passes through it', transfer.kobo, 750);
test('and the base can see which subclass is being built', Payment.built, ['Transfer']);
test('the subclass instance is both classes', [transfer instanceof Transfer, transfer instanceof Payment], [true, true]);

The first assertion is the whole mechanism: seen reads ['Fee', 'no new', 'Fee'] because the flat call recorded 'no new' and then recursed with new, recording 'Fee' again. Before new.target the idiom was if (!(this instanceof Fee)), which is wrong in strict mode where this is undefined and reading it throws.

The abstract-base pattern is the useful half. new.target === Payment is true only when someone instantiated the base directly; through super() it is the subclass, which is also how a base constructor can log or register what is being built. Note that classes need no new.target guard against flat calls — the language already throws — so new.target in a class body is about the abstract check and about telling subclass from base.

50 A prototype method against a field holding an arrow

fair

Give a counter both a normal method and a field holding an arrow function. Detach each from the instance and call it. Then show that the own field shadows a subclass method of the same name.

Stuck? Three hints, smallest first
  1. 1. A method is on the prototype and gets its this from the call site, so a detached reference has none.
  2. 2. An arrow in a field captures this when the instance is built, so it survives detachment.
  3. 3. Own properties are found before prototype ones, and a field is an own property.

The answer

class Counter {
  count = 0;
  bump() {                                    // prototype method
    this.count++;
    return this.count;
  }
  bumpArrow = () => {                         // own field, this captured
    this.count++;
    return this.count;
  };
}

class Sub extends Counter {
  bumpArrow() {                               // never reached
    return 'from the subclass prototype';
  }
}

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

const counter = new Counter();
const detachedMethod = counter.bump;
const detachedArrow = counter.bumpArrow;
const rebound = counter.bump.bind(counter);
const looseCall = attempt(() => detachedMethod());

console.log('detached method:', looseCall);
console.log('own keys       :', Object.keys(counter));
console.log('arrow on prototype?', Object.prototype.hasOwnProperty.call(Counter.prototype, 'bumpArrow'));
console.log('subclass call  :', new Sub().bumpArrow(), '<- the inherited field won');

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('called on the instance, both forms work', [counter.bump(), counter.bumpArrow()], [1, 2]);
test('detached, the prototype method has no this and throws in strict mode', looseCall, 'TypeError');
test('the arrow field kept the this it captured', detachedArrow(), 3);
test('bind repairs the prototype method', rebound(), 4);
test('the arrow is an own property; the method is not', Object.keys(counter), ['count', 'bumpArrow']);
test('nor is the arrow on the prototype', Object.prototype.hasOwnProperty.call(Counter.prototype, 'bumpArrow'), false);
test('every instance shares one prototype method', new Counter().bump === counter.bump, true);
test('but each gets its own arrow', new Counter().bumpArrow === counter.bumpArrow, false);
test('and the inherited own field shadows the subclass method of the same name', new Sub().bumpArrow(), 1);

A field holding an arrow is the standard answer to "my callback lost this": pass obj.bumpArrow to setTimeout or an event listener and it still works, where obj.bump would throw. The cost is in the seventh and eighth assertions — one function per instance instead of one per class — which matters when the instances are thousands of list rows and not one counter.

The last assertion is the trap. Counter's field initialiser installs an own bumpArrow on every instance, including instances of Sub, and own properties are found before prototype ones. So Sub's method is unreachable: the override silently does nothing, with no error to tell you. If a member is meant to be overridable, write it as a method.

51 for...of against for...in

easy

Loop over the same array with for...of and for...in and report what each one yields, including the type of what for...in gives you. Add a stray property and a hole to the array, then try for...of over a plain object.

Stuck? Three hints, smallest first
  1. 1. for...of asks for the values through the iterator. for...in lists enumerable keys.
  2. 2. Array indices are string keys, and for...in also walks the prototype chain and any extra property.
  3. 3. A plain object has no Symbol.iterator, so for...of over one throws.

The answer

const areas = ['Ikeja', 'Yaba', 'Lekki'];
areas.note = 'stray property';

const values = [];
for (const area of areas) values.push(area);

const keys = [];
for (const key in areas) keys.push(key);
const keyTypes = keys.map((k) => typeof k);

const sparse = [1, , 3];                      // one hole in the middle
const ofSparse = [];
for (const v of sparse) ofSparse.push(v === undefined ? 'hole' : v);
const inSparse = [];
for (const i in sparse) inSparse.push(i);

const parent = { inherited: 'from the prototype' };
const child = Object.create(parent);
child.own = 'mine';
const inChild = [];
for (const key in child) inChild.push(key);

const attempt = (fn) => { try { return fn(); } catch (e) { return e.constructor.name; } };
const overObject = attempt(() => {
  const out = [];
  for (const v of { a: 1 }) out.push(v);
  return out;
});

console.log('for...of :', values);
console.log('for...in :', keys, '| types:', keyTypes);
console.log('sparse   : of ->', ofSparse, '| in ->', inSparse);
console.log('inherited: for...in ->', inChild, '| Object.keys ->', Object.keys(child));
console.log('for...of over a plain object:', overObject);

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...of yields the values', values, ['Ikeja', 'Yaba', 'Lekki']);
test('for...in yields every enumerable key, the stray one included', keys, ['0', '1', '2', 'note']);
test('and those keys are strings, not numbers', keyTypes, ['string', 'string', 'string', 'string']);
test('for...of visits a hole as undefined', ofSparse, [1, 'hole', 3]);
test('for...in skips the hole entirely', inSparse, ['0', '2']);
test('for...in walks the prototype chain', inChild, ['own', 'inherited']);
test('Object.keys stops at own keys', Object.keys(child), ['own']);
test('for...of over a plain object throws, because it is not iterable', overObject, 'TypeError');
test('the array length ignores the stray property', areas.length, 3);

for...in is not the array loop. Three assertions here say why: it hands you '0' rather than 0, so key + 1 is '01'; it includes anything else enumerable on the object, including a property a library added; and it climbs the prototype chain. It exists for objects, and even there Object.keys or Object.entries is usually what you meant.

The hole is the sharpest difference. [1, , 3] has no key '1' at all, so for...in jumps from '0' to '2' while for...of reports three values with the middle one undefined. That is the iterator protocol counting to length rather than consulting the keys — which is also why for...of is the loop that agrees with length.

52 An array-like is not iterable

fair

Take an object with numeric keys and a length, try to spread it, then convert it properly. Show two ways, show Array.from's mapping argument, and show that arguments is both array-like and iterable while your object is only the first.

Stuck? Three hints, smallest first
  1. 1. Spread and for...of need Symbol.iterator. Array-like means only "has length and index keys".
  2. 2. Array.from works on both, and takes a second argument that maps each element as it goes.
  3. 3. Array.from({ length: n }, (_, i) => ...) generates a range from nothing at all.

The answer

const arrayLike = { 0: 'gari', 1: 'rice', length: 2 };

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

const spreadFails = attempt(() => [...arrayLike]);
const viaFrom = Array.from(arrayLike);
const viaSlice = Array.prototype.slice.call(arrayLike);
const mapped = Array.from(arrayLike, (item) => item.toUpperCase());
const range = Array.from({ length: 4 }, (_, i) => i * 250);
const noLength = Array.from({ a: 1 });
const shortLength = Array.from({ 0: 'a', 1: 'b', length: 1 });

function collect() {
  return [...arguments];                      // arguments is iterable
}
const spreadArguments = collect('gari', 'rice');

const iteratorPresence = [typeof arrayLike[Symbol.iterator], typeof [][Symbol.iterator]];
const stringSpread = [...'Yaba'];
const setSpread = [...new Set(['a', 'a', 'b'])];

console.log('spread   :', spreadFails);
console.log('Array.from:', viaFrom, '| slice.call:', viaSlice);
console.log('mapped   :', mapped, '| generated range:', range);
console.log('no length:', noLength, '| length shorter than the keys:', shortLength);
console.log('arguments:', spreadArguments, '| iterator present?', iteratorPresence);

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('spreading an array-like throws, because it has no iterator', spreadFails, 'TypeError');
test('Array.from reads length and the index keys instead', viaFrom, ['gari', 'rice']);
test('the pre-ES6 slice.call trick does the same job', viaSlice, ['gari', 'rice']);
test('Array.from takes a mapping function as its second argument', mapped, ['GARI', 'RICE']);
test('so length alone is enough to generate a range', range, [0, 250, 500, 750]);
test('length decides what is read: none gives an empty array, a short one truncates', [noLength, shortLength], [[], ['a']]);
test('arguments is array-like AND iterable, so spread works on it', spreadArguments, ['gari', 'rice']);
test('only the real array has Symbol.iterator', iteratorPresence, ['undefined', 'function']);
test('strings and Sets have it too, so both spread', [stringSpread, setSpread], [['Y', 'a', 'b', 'a'], ['a', 'b']]);

Two unrelated ideas wear similar clothes. Array-like is a shape: a length and keys 0..length-1, which is what arguments, a NodeList and an HTMLCollection all have. Iterable is a capability: a Symbol.iterator method, which is what spread, for...of, destructuring, Promise.all and new Map(...) actually require. Many DOM collections now have both; a hand-built object has only the first, which is why the spread throws.

Array.from is the bridge and handles either. The seventh assertion is the trap in the array-like path: it trusts length completely, so a wrong length silently truncates or pads with undefined rather than complaining. Its mapping argument is not a nicety either — Array.from({length: 3}).map(...) works, but new Array(3).map(...) does not, because holes are skipped.

53 Write your own Symbol.iterator

fair

Make your own class iterable by giving it a [Symbol.iterator]() method that returns an object with a next. Prove it works with spread, destructuring and for...of with a break, and that a fresh iterator per call lets it be looped twice. Then write a range helper the same way.

Stuck? Three hints, smallest first
  1. 1. next() returns { value, done }. Consumers stop as soon as done is true.
  2. 2. Keep the cursor in a closure inside the method, so each call to [Symbol.iterator]() starts again from zero.
  3. 3. A symbol-keyed method is not a string key, so Object.keys will not show it.

The answer

class Route {
  constructor(...stops) {
    this.stops = stops;
  }
  [Symbol.iterator]() {
    let i = 0;
    const stops = this.stops;                 // captured, so next() needs no this
    return {
      next: () => (i < stops.length
        ? { value: stops[i++], done: false }
        : { value: undefined, done: true }),
    };
  }
}

const range = (from, to) => ({
  [Symbol.iterator]() {
    let n = from;
    return { next: () => (n <= to ? { value: n++, done: false } : { value: undefined, done: true }) };
  },
});

const route = new Route('Oshodi', 'Ikeja', 'Agege');

const walked = [...route];
const twice = [[...route].length, [...route].length];
const [first, ...rest] = route;

const upToIkeja = [];
for (const stop of route) {
  if (stop === 'Ikeja') break;                // stops pulling
  upToIkeja.push(stop);
}

const firstResult = route[Symbol.iterator]().next();
const numbers = [...range(1, 4)];
const sum = numbers.reduce((a, b) => a + b, 0);

console.log('spread      :', walked);
console.log('twice       :', twice, '<- a fresh iterator each time');
console.log('destructured:', first, rest);
console.log('break       :', upToIkeja);
console.log('first result:', firstResult, '| range:', numbers, 'sum', sum);

Output


      
9 checks that prove this answer

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

9 checks

test('spread drives the iterator to done', walked, ['Oshodi', 'Ikeja', 'Agege']);
test('a fresh iterator per call means it can be consumed twice', twice, [3, 3]);
test('destructuring pulls through the same protocol', [first, rest], ['Oshodi', ['Ikeja', 'Agege']]);
test('break stops pulling, so the rest is never produced', upToIkeja, ['Oshodi']);
test('next() hands back a result object, in that key order', firstResult, { value: 'Oshodi', done: false });
test('an object literal can be iterable too, with nothing but a next', numbers, [1, 2, 3, 4]);
test('and once spread it is an ordinary array', sum, 10);
test('the symbol-keyed method is not a string key', Object.keys(route), ['stops']);
test('but it is there, under the symbol', typeof route[Symbol.iterator], 'function');

Iterability is a method, not a class you inherit from. Anything with a [Symbol.iterator]() returning an object with a next() works with spread, for...of, array and object destructuring, Array.from, new Map, new Set, Promise.all and yield* — the whole language, from one method.

The second assertion is the design decision. The cursor i lives inside [Symbol.iterator](), so every consumer gets its own, and the route can be walked as often as you like. Hoist i out to the instance, or return this, and the object becomes single-use: the second loop finds it already exhausted and yields nothing, with no error. That is precisely the behaviour of a generator, which is the next exercise but one. Note also that break is honoured by simply not calling next again — there is nothing to clean up here, but if your iterator held a file handle you would add a return() method for it.

54 A first generator, and what yield gives back

fair

Write a function* that yields three stops and then returns a value. Prove that calling it runs none of the body, that each next() resumes where the last one stopped, and that the returned value is not one of the yielded ones.

Stuck? Three hints, smallest first
  1. 1. Calling a generator function builds a generator object and runs nothing. The body starts on the first next().
  2. 2. next() hands back { value, done }, and yield is where the body pauses.
  3. 3. A return value arrives on the result where done first becomes true, which is exactly the one spread and for...of throw away.

The answer

function* stops() {
  yield 'Oshodi';
  yield 'Ikeja';
  yield 'Agege';
  return 'depot';                             // not a yielded value
}

const log = [];
function* traced() {
  log.push('started');
  yield 1;
  log.push('resumed');
  yield 2;
  log.push('finished');
}

const it = traced();
const beforeAnyNext = [...log];               // still empty: nothing has run
const firstPull = it.next();
const afterFirst = [...log];
const secondPull = it.next();
const thirdPull = it.next();
const fourthPull = it.next();

const spread = [...stops()];
const viaForOf = [];
for (const stop of stops()) viaForOf.push(stop);

const g = stops();
g.next(); g.next(); g.next();
const withReturn = g.next();

const kinds = [typeof stops, typeof stops()];

console.log('before any next():', beforeAnyNext);
console.log('first pull       :', firstPull, '| log:', afterFirst);
console.log('second, third    :', secondPull, thirdPull);
console.log('log at the end   :', log);
console.log('spread           :', spread, '| return value:', withReturn);
console.log('typeof           :', kinds);

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('calling a generator function runs none of the body', beforeAnyNext, []);
test('the first next() runs as far as the first yield and stops', [firstPull, afterFirst], [{ value: 1, done: false }, ['started']]);
test('the next call resumes from there', secondPull, { value: 2, done: false });
test('when the body ends, done is true with no value, and pulling again is harmless', [thirdPull.done, thirdPull.value === undefined, fourthPull.done], [true, true, true]);
test('the body ran to the end exactly once', log, ['started', 'resumed', 'finished']);
test('spread and for...of both collect the yielded values', [spread, viaForOf], [['Oshodi', 'Ikeja', 'Agege'], ['Oshodi', 'Ikeja', 'Agege']]);
test('the returned value is not among them', spread.includes('depot'), false);
test('it arrives on the result that reports done', [withReturn.value, withReturn.done], ['depot', true]);
test('a generator function is a function; calling it gives an object', kinds, ['function', 'object']);

A generator is a function you can pause. The first assertion is the part that surprises people: traced() looks like a call but is closer to building a to-do list — no line of the body runs until something pulls. That laziness is the whole point, and it is what makes an endless sequence possible.

The return value is a trap worth remembering. It is real, and you can see it with a manual next(), but every consumer built on the iteration protocol — spread, for...of, destructuring, Array.from — stops as soon as done is true and discards the value that came with it. So return inside a generator is for ending it, not for producing a final item. If the value matters, yield it.

55 A generator that never ends, and taking five

fair

Write a generator that counts for ever, then write take(iterable, n) and takeWhile(iterable, ok) as generators so you can get a finite answer out of it. Prove the source only produced as many values as were asked for.

Stuck? Three hints, smallest first
  1. 1. while (true) yield n++; is fine, because nothing runs until it is pulled.
  2. 2. take loops the source with for...of and returns once it has enough.
  3. 3. Count the source’s work in a variable outside it, and the laziness becomes something you can assert.

The answer

function* naturals(from = 1) {
  let n = from;
  while (true) yield n++;                     // never ends, and never runs unpulled
}

function* take(iterable, count) {
  if (count <= 0) return;
  let left = count;
  for (const item of iterable) {
    yield item;
    if (--left === 0) return;                 // stops pulling the source
  }
}

function* takeWhile(iterable, ok) {
  for (const item of iterable) {
    if (!ok(item)) return;
    yield item;
  }
}

function* mapped(iterable, fn) {
  for (const item of iterable) yield fn(item);
}

function* fib() {
  let [a, b] = [0, 1];
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

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

const firstFive = [...take(naturals(), 5)];
const firstThree = [...take(counted(), 3)];
const nothing = [...take(naturals(), 0)];
const fares = [...take(mapped(naturals(), (n) => n * 25000), 3)];
const firstEight = [...take(fib(), 8)];
const underHundred = [...takeWhile(fib(), (n) => n < 100)];
const bothProtocols = [typeof naturals().next, typeof naturals()[Symbol.iterator]];

console.log('take 5    :', firstFive);
console.log('laziness  :', firstThree, 'and the source ran', produced, 'times');
console.log('take 0    :', nothing, '| mapped then taken:', fares);
console.log('fib 8     :', firstEight);
console.log('under 100 :', underHundred);

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('five values out of an endless source', firstFive, [1, 2, 3, 4, 5]);
test('and the source produced exactly three when three were asked for', [firstThree, produced], [[1, 2, 3], 3]);
test('taking none pulls nothing at all', nothing, []);
test('generators compose, and the composition stays lazy', fares, [25000, 50000, 75000]);
test('the same shape gives fibonacci', firstEight, [0, 1, 1, 2, 3, 5, 8, 13]);
test('takeWhile stops at the first value that fails the test', underHundred.length, 12);
test('so the last one through is the largest under a hundred', underHundred[underHundred.length - 1], 89);
test('a generator object is an iterator and an iterable at the same time', bothProtocols, ['function', 'function']);

An endless generator is safe because it is a description, not a computation: naturals() holds a plan and a cursor, and while (true) only advances when someone pulls. The second assertion is the one that proves it — three values requested, three iterations run, no fourth. Chain mapped in front and nothing changes: no intermediate array is ever built, unlike arr.map(...).slice(0, 3), which maps everything first.

The danger is on you. [...naturals()], Array.from(naturals()) or a for...of with no break will run until the tab dies; the loop guard on this page rewrites while and for headers, but a for...of has no condition to rewrite, so that is the one shape it cannot save you from. Always put a take, a takeWhile or a break between an endless source and a consumer that wants everything.

56 A generator is its own iterator, so it runs out

fair

Spread the same generator object twice and report what the second spread gives you. Show that a break closes it permanently. Then make an object and a class that can be looped as often as you like, by handing back a fresh generator each time.

Stuck? Three hints, smallest first
  1. 1. A generator object’s [Symbol.iterator]() returns this, so it is the iterator, not a factory of iterators.
  2. 2. Breaking out of a for...of calls the iterator’s return(), which finishes a generator for good.
  3. 3. The fix is one level of indirection: an object whose [Symbol.iterator] is itself a generator method.

The answer

function* stops() {
  yield 'Oshodi';
  yield 'Ikeja';
}

const gen = stops();
const isOwnIterator = gen[Symbol.iterator]() === gen;
const firstSpread = [...gen];
const secondSpread = [...gen];                // empty: already drained
let loopsRun = 0;
for (const stop of gen) loopsRun++;           // body never entered

const split = stops();
const pulledOne = split.next().value;
const remainder = [...split];                 // resumes, does not restart

const closedByBreak = stops();
const beforeBreak = [];
for (const stop of closedByBreak) {
  beforeBreak.push(stop);
  break;                                      // calls return() on the generator
}
const afterBreak = [...closedByBreak];

const route = {
  *[Symbol.iterator]() {                      // a NEW generator per call
    yield 'Oshodi';
    yield 'Ikeja';
  },
};

class Line {
  constructor(...stops) { this.stops = stops; }
  *[Symbol.iterator]() {
    for (const stop of this.stops) yield stop;
  }
}
const line = new Line('Yaba', 'Surulere');

console.log('own iterator?  :', isOwnIterator);
console.log('first spread   :', firstSpread, '| second:', secondSpread);
console.log('for...of after :', loopsRun, 'iterations');
console.log('split          :', pulledOne, remainder);
console.log('break then read:', beforeBreak, afterBreak);
console.log('route twice    :', [...route], [...route]);
console.log('class twice    :', [...line], [...line]);

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 generator object hands back itself as its iterator', isOwnIterator, true);
test('so the first spread drains it', firstSpread, ['Oshodi', 'Ikeja']);
test('and the second gets nothing, with no error to warn you', secondSpread, []);
test('a for...of over an exhausted generator runs zero times', loopsRun, 0);
test('a partly read generator resumes rather than restarting', [pulledOne, remainder], ['Oshodi', ['Ikeja']]);
test('breaking out of a for...of closes the generator permanently', [beforeBreak, afterBreak], [['Oshodi'], []]);
test('an object with a generator method gives a fresh iterator each time', [[...route], [...route]], [['Oshodi', 'Ikeja'], ['Oshodi', 'Ikeja']]);
test('and so does a class, so an instance can be looped as often as you like', [[...line], [...line]], [['Yaba', 'Surulere'], ['Yaba', 'Surulere']]);
test('the class instance still stores plain data beside it', Object.keys(line), ['stops']);

The distinction is iterable against iterator. An iterator has a cursor and is used up; an iterable is something that can hand out iterators. A generator object is both, and its [Symbol.iterator]() returns itself — so passing one to two consumers does not give them a sequence each, it splits one sequence between them, and passing an exhausted one gives the second consumer an empty loop with no complaint. That silence is the bug: no error, just nothing.

The break case catches people who assume a paused generator can be resumed later. for...of is tidy on exit: it calls return() on the iterator, which runs any finally in the generator and closes it. Useful for releasing a resource, fatal if you meant to come back. The cure for both problems is to expose an iterable — an object or class with a *[Symbol.iterator]() method — and let each consumer call it. Arrays, Maps, Sets and strings all work that way, which is why you can loop them twice without thinking about it.

57 keys, values and entries on an array and a Map

fair

Number a list with array.entries() and a destructuring for...of. Then do the same over a Map, note the argument order Map.prototype.forEach uses, and show that the iterator these methods return is single-use while Object.keys hands back an array.

Stuck? Three hints, smallest first
  1. 1. for (const [i, item] of arr.entries()) is the index-and-value loop.
  2. 2. A Map iterates as entries by default, so [...map] and [...map.entries()] agree.
  3. 3. forEach on both arrays and Maps passes the value first, then the key.

The answer

const stalls = ['gari', 'rice', 'beans'];

const arrayEntries = [...stalls.entries()];
const numbered = [];
for (const [i, name] of stalls.entries()) numbered.push(`${i + 1}. ${name}`);
const arrayKeys = [...stalls.keys()];
const keyKinds = arrayKeys.map((k) => typeof k);
const arrayValues = [...stalls.values()];

const prices = new Map([['gari', 250000], ['rice', 480000]]);
const mapDefault = [...prices];
const mapEntries = [...prices.entries()];
const total = [...prices.values()].reduce((sum, kobo) => sum + kobo, 0);

const forEachOrder = [];
prices.forEach((value, key) => forEachOrder.push(`${key}=${value}`));

const iterator = prices.keys();
const drained = [...iterator];
const again = [...iterator];                  // an iterator is used up

const shapes = [Array.isArray(Object.keys({ a: 1 })), Array.isArray([...prices.keys()]), Array.isArray(prices.keys())];

console.log('array entries:', arrayEntries);
console.log('numbered     :', numbered);
console.log('array keys   :', arrayKeys, keyKinds);
console.log('map default  :', mapDefault);
console.log('total        :', total, '| forEach order:', forEachOrder);
console.log('iterator     :', drained, 'then', again);

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('entries on an array pairs index with value', arrayEntries, [[0, 'gari'], [1, 'rice'], [2, 'beans']]);
test('destructuring the pair in a for...of numbers a list', numbered, ['1. gari', '2. rice', '3. beans']);
test('array keys are real numbers here, unlike for...in', [arrayKeys, keyKinds], [[0, 1, 2], ['number', 'number', 'number']]);
test('values is just the elements again', arrayValues, ['gari', 'rice', 'beans']);
test('a Map iterates as entries by default', [mapDefault, mapEntries], [[['gari', 250000], ['rice', 480000]], [['gari', 250000], ['rice', 480000]]]);
test('so its values can be totalled', total, 730000);
test('forEach on a Map hands you the value first and the key second', forEachOrder, ['gari=250000', 'rice=480000']);
test('the iterator these methods return is single-use', [drained, again], [['gari', 'rice'], []]);
test('Object.keys returns an array; keys() returns an iterator', shapes, [true, true, false]);

entries() is the answer to "I need the index too" without a for loop or a stray counter, and it destructures in the loop header so the body never mentions [0] or [1]. The keys() on an array is worth contrasting with for...in from exercise 51: this one yields the number 0, that one yields the string '0'.

Two traps sit close together. forEach passes value before key, on arrays and Maps alike, so map.forEach((key, value) => ...) reads perfectly and is backwards — while for...of over the same Map gives [key, value] in that order. And the object these methods return is an iterator, not a collection: spread it twice and the second attempt is empty, because it has no rewind. Object.keys is different in kind — a plain array, reusable, with length and every array method.

58 A Map keyed by anything, including NaN

fair

Use an object, a function, a symbol, a number, a string and NaN as Map keys. Show that lookup is by identity for objects, that 1 and '1' are different keys, that NaN works where indexOf cannot find it, and that -0 and 0 are the same key.

Stuck? Three hints, smallest first
  1. 1. A Map key can be any value at all, and it is never converted to a string.
  2. 2. Objects are compared by identity, so an equal-looking literal is a different key.
  3. 3. Map uses SameValueZero: NaN equals NaN, and -0 equals 0.

The answer

const stall = { id: 'stall-1' };
const callback = () => 'noop';
const area = Symbol('area');

const notes = new Map();
notes
  .set(stall, 'Balogun')
  .set(callback, 'a function key')
  .set(area, 'a symbol key')
  .set(1, 'the number one')
  .set('1', 'the string one')
  .set(NaN, 'not a number')
  .set(true, 'a boolean key');

const byIdentity = notes.get(stall);
const byLookalike = notes.get({ id: 'stall-1' });
const numberVersusString = [notes.get(1), notes.get('1')];
const nanKey = notes.get(NaN);
const byFunction = notes.get(callback);
const bySymbol = notes.get(area);
const sizeBefore = notes.size;

const indexOfNaN = [NaN, 1].indexOf(NaN);
const findIndexOfNaN = [NaN, 1].findIndex(Number.isNaN);
const includesNaN = [NaN, 1].includes(NaN);

const zeroes = new Map().set(0, 'zero');
const viaMinusZero = zeroes.get(-0);

const removals = [notes.delete(callback), notes.delete(callback)];
const stillThere = notes.has(callback);

console.log('by identity  :', byIdentity, '| by a lookalike:', byLookalike);
console.log('1 vs "1"     :', numberVersusString);
console.log('NaN as a key :', nanKey, '| indexOf(NaN):', indexOfNaN, '| includes(NaN):', includesNaN);
console.log('-0 finds 0   :', viaMinusZero, '| size:', sizeBefore);
console.log('delete twice :', removals, '| has after:', stillThere);

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 object works as a key', byIdentity, 'Balogun');
test('but lookup is by identity, so an equal-looking object misses', byLookalike === undefined, true);
test('the number 1 and the string "1" are two different keys', numberVersusString, ['the number one', 'the string one']);
test('NaN is a usable key, because Map compares with SameValueZero', nanKey, 'not a number');
test('indexOf can never find NaN, though findIndex and includes can', [indexOfNaN, findIndexOfNaN, includesNaN], [-1, 0, true]);
test('minus zero and zero are the same key', viaMinusZero, 'zero');
test('functions and symbols are keys like any other value', [byFunction, bySymbol], ['a function key', 'a symbol key']);
test('size counts the pairs, whatever the keys are', sizeBefore, 7);
test('delete reports whether it removed anything, and has agrees after', [removals, stillThere], [[true, false], false]);

The identity rule cuts both ways. It is what makes a Map the right store for "extra data about this exact object" — a DOM node, a request, a socket — and it is why notes.get({ id: 'stall-1' }) misses: the literal is a different object, however identical it looks. If you want value equality, key on something primitive you derive yourself.

NaN is the pleasant surprise. Map, Set, includes and findIndex all use SameValueZero, under which NaN equals itself, so NaN can be a key and can be found. indexOf is the odd one out: it uses strict equality, and NaN === NaN is false, so it returns -1 for a value that is genuinely in the array. The same rule folds -0 into 0, which is almost always what you want and worth knowing before you rely on the sign.

59 Map insertion order against object key order

fair

Insert the same four keys, three integer-like and one not, into a plain object and into a Map, and compare the order they come back in. Then show what re-setting an existing Map key does to its position, and what deleting and re-adding does.

Stuck? Three hints, smallest first
  1. 1. An object puts integer-like keys first, in ascending numeric order, whatever order you added them.
  2. 2. A Map has no such rule: it is strictly insertion order, and the key types are preserved.
  3. 3. Overwriting a Map key keeps its place. Deleting it and setting it again does not.

The answer

const asObject = {};
asObject[3] = 'third';
asObject[1] = 'first';
asObject.zone = 'B';
asObject[2] = 'second';
const objectOrder = Object.keys(asObject);

const asMap = new Map();
asMap.set(3, 'third').set(1, 'first').set('zone', 'B').set(2, 'second');
const mapOrder = [...asMap.keys()];
const mapKeyKinds = mapOrder.map((k) => typeof k);
const objectKeyKinds = objectOrder.map((k) => typeof k);

const reset = new Map([['a', 1], ['b', 2], ['c', 3]]);
reset.set('a', 99);
const afterOverwrite = [...reset.keys()];
reset.delete('b');
reset.set('b', 2);
const afterDeleteAndSet = [...reset.keys()];

const counts = [Object.keys(asObject).length, asMap.size];
const inheritedOnObject = 'toString' in {};
const inheritedOnMap = new Map().has('toString');

console.log('object order:', objectOrder, objectKeyKinds);
console.log('map order   :', mapOrder, mapKeyKinds);
console.log('overwrite   :', afterOverwrite, '| delete then set:', afterDeleteAndSet);
console.log('counts      :', counts);
console.log('inherited   : object', inheritedOnObject, '| map', inheritedOnMap);

Output


      
8 checks that prove this answer

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

8 checks

test('an object sorts integer-like keys ascending and leaves the rest after them', objectOrder, ['1', '2', '3', 'zone']);
test('a Map returns keys in the order they went in', mapOrder, [3, 1, 'zone', 2]);
test('and keeps their types, where an object stringifies every one', [mapKeyKinds, objectKeyKinds], [['number', 'number', 'string', 'number'], ['string', 'string', 'string', 'string']]);
test('overwriting a value leaves the key where it was', afterOverwrite, ['a', 'b', 'c']);
test('but deleting and re-adding moves it to the end', afterDeleteAndSet, ['a', 'c', 'b']);
test('the values survived the overwrite and the re-add', [reset.get('a'), reset.get('b')], [99, 2]);
test('size is a property on a Map and a count you compute on an object', counts, [4, 4]);
test('and an empty Map has no inherited keys to trip a lookup', [inheritedOnObject, inheritedOnMap], [true, false]);

Object key order is specified, and the specification is stranger than most people expect: integer-like keys first in ascending numeric order, then string keys in insertion order, then symbols. So an object built from ids [3, 1, 2] reads back 1, 2, 3, and there is nothing you can do about it. Exercise 37 hit the same wall when inverting a table of rents.

A Map has one rule — insertion order — and it never touches your keys, so the number 3 stays a number. The two ordering assertions in the middle are the practical ones: overwriting a keeps its position, which is what you want for a cache or an ordered form; deleting and re-adding sends it to the back, which is exactly how you would implement least-recently-used eviction. Reach for a Map whenever the order of arrival is part of the meaning.

60 A Set for uniqueness and membership

easy

Deduplicate a list of arrivals with a Set, ask it about membership, and show that add of something already present changes nothing. Then check what it does with NaN, with -0, and with two equal-looking objects, and build a union and an intersection.

Stuck? Three hints, smallest first
  1. 1. [...new Set(list)] is the whole deduplication idiom.
  2. 2. has is the membership test, and add returns the set so calls chain.
  3. 3. A Set compares like a Map: NaN equals itself, -0 folds into 0, objects go by identity.

The answer

const arrivals = ['Ikeja', 'Yaba', 'Ikeja', 'Lekki', 'Yaba'];

const unique = [...new Set(arrivals)];
const seen = new Set(arrivals);
const size = seen.size;
const membership = [seen.has('Yaba'), seen.has('Epe')];
const returned = seen.add('Ikeja');           // already there
const sizeAfterAdd = seen.size;
const chained = new Set().add('gari').add('rice').size;

const oddValues = new Set([NaN, NaN, 0, -0]);
const objects = new Set([{ id: 1 }, { id: 1 }]);
const order = [...new Set([3, 1, 3, 2, 1])];

const left = ['gari', 'rice'];
const right = ['rice', 'beans'];
const union = [...new Set([...left, ...right])];
const rightSet = new Set(right);
const intersection = [...new Set(left)].filter((item) => rightSet.has(item));

console.log('unique     :', unique, '| size:', size);
console.log('membership :', membership);
console.log('add again  :', sizeAfterAdd, '| add returned the set:', returned === seen);
console.log('NaN and -0 :', oddValues.size, '| two equal objects:', objects.size);
console.log('order      :', order);
console.log('union      :', union, '| intersection:', intersection);

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 Set drops the repeats', unique, ['Ikeja', 'Yaba', 'Lekki']);
test('size is the count of distinct values', size, 3);
test('has answers membership directly', membership, [true, false]);
test('adding something already present changes nothing, and returns the set', [sizeAfterAdd, returned === seen], [3, true]);
test('so add chains', chained, 2);
test('NaN deduplicates against itself and -0 folds into 0', oddValues.size, 2);
test('but two equal-looking objects are two distinct values', objects.size, 2);
test('the order is insertion order, not sorted', order, [3, 1, 2]);
test('union is a spread and intersection is a filter', [union, intersection], [['gari', 'rice', 'beans'], ['rice']]);

[...new Set(list)] is the shortest correct deduplication in the language, and it beats filter((v, i, a) => a.indexOf(v) === i) twice over: it is one pass rather than a scan per element, and it handles NaN, which indexOf cannot find at all.

The membership case is the one people under-use. set.has(x) is roughly constant time while array.includes(x) walks the array, so building a Set once and testing against it inside a loop turns quadratic work into linear — which is exactly what the intersection above does. Two cautions: identity again, so a Set of freshly parsed objects will never deduplicate no matter how identical they look; and a Set is not JSON, so JSON.stringify(set) is '{}' and you must spread it first.

61 Maps and Sets to arrays and back

fair

Convert a Map to pairs, to a plain object and back again, map over it with Array.from, and filter it. Then show what JSON.stringify makes of a Map and a Set, and tally a list with a Map.

Stuck? Three hints, smallest first
  1. 1. [...map] gives pairs; Object.fromEntries(map) gives an object; new Map(Object.entries(obj)) comes back.
  2. 2. Array.from takes a mapping function, and the element it passes is the [key, value] pair, so destructure it.
  3. 3. Neither a Map nor a Set has own enumerable properties, which decides what JSON can see.

The answer

const prices = new Map([['gari', 250000], ['rice', 480000]]);

const asPairs = [...prices];
const asObject = Object.fromEntries(prices);
const backToMap = new Map(Object.entries(asObject));
const roundTripped = [...backToMap];
const keysArray = Array.from(prices.keys());
const labelled = Array.from(prices, ([name, kobo]) => `${name}: ₦${kobo / 100}`);

const stock = new Set(['gari', 'rice']);
const jsonAttempts = [JSON.stringify(prices), JSON.stringify(stock)];
const jsonAfterConverting = [JSON.stringify(Object.fromEntries(prices)), JSON.stringify([...stock])];

const tally = new Map();
for (const area of ['Ikeja', 'Yaba', 'Ikeja']) tally.set(area, (tally.get(area) ?? 0) + 1);
const tallyAsObject = Object.fromEntries(tally);

const dear = new Map([...prices].filter(([, kobo]) => kobo > 300000));
const doubled = new Map([...prices].map(([name, kobo]) => [name, kobo * 2]));

console.log('pairs     :', asPairs);
console.log('object    :', asObject, '| back to a Map:', roundTripped);
console.log('labelled  :', labelled);
console.log('JSON      :', jsonAttempts, '<- both empty');
console.log('converted :', jsonAfterConverting);
console.log('tally     :', tallyAsObject, '| filtered:', [...dear]);

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('spreading a Map gives its pairs', asPairs, [['gari', 250000], ['rice', 480000]]);
test('fromEntries turns those pairs into a plain object', asObject, { gari: 250000, rice: 480000 });
test('and Object.entries takes it back to a Map unchanged', roundTripped, [['gari', 250000], ['rice', 480000]]);
test('Array.from works on any of its iterators', keysArray, ['gari', 'rice']);
test('and its mapping function receives each pair', labelled, ['gari: ₦2500', 'rice: ₦4800']);
test('JSON.stringify finds nothing in a Map or a Set', jsonAttempts, ['{}', '{}']);
test('so convert before serialising', jsonAfterConverting, ['{"gari":250000,"rice":480000}', '["gari","rice"]']);
test('a Map is the natural tally, and fromEntries reports it', tallyAsObject, { Ikeja: 2, Yaba: 1 });
test('filtering or mapping a Map means going out to an array and back', [[...dear], [...doubled]], [[['rice', 480000]], [['gari', 500000], ['rice', 960000]]]);

Object.fromEntries accepts a Map directly, and new Map accepts an array of pairs, so the round trip is two calls with no loop. The one asymmetry: keys that were not strings come back as strings through the object, so a Map keyed by numbers does not survive the detour intact.

The JSON pair is the assertion to remember. A Map keeps its contents in internal slots, not in own enumerable properties, so JSON.stringify(map) is '{}' — no error, no warning, just an empty object arriving at your API. Same for a Set. Convert first, every time, and on the way back rebuild the Map rather than working with the plain object, or you inherit the key-order and prototype problems the Map was chosen to avoid. Note also that a Map has no map or filter of its own: spread, use the array methods, and construct a new Map from the result.

62 Why a plain object is a poor Map

fair

Use a plain object as a lookup table and find the four ways it lets you down: inherited keys answer for keys you never set, every key is stringified so two objects collide, a key called hasOwnProperty breaks the usual check, and __proto__ refuses to be stored. Show what a Map and Object.create(null) do instead.

Stuck? Three hints, smallest first
  1. 1. An empty object already has toString, constructor and more through its prototype.
  2. 2. Object keys are strings, so any object key becomes "[object Object]".
  3. 3. Object.hasOwn(obj, key) and Object.create(null) are the two repairs; a Map avoids the problem entirely.

The answer

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

const store = {};
const inheritedHit = ['toString' in store, typeof store.toString];
const mapMiss = new Map().has('toString');

store[1] = 'a number key';
const stringifiedKey = [Object.keys(store), store['1'] === store[1]];

const collisions = (() => {
  const o = {};
  o[{ id: 1 }] = 'first';
  o[{ id: 2 }] = 'second';                    // the same key as above
  return [Object.keys(o), o[{ id: 99 }]];
})();

const shadowed = { hasOwnProperty: 'not a function at all' };
const brokenCheck = attempt(() => shadowed.hasOwnProperty('x'));
const properCheck = Object.hasOwn(shadowed, 'hasOwnProperty');

const protoWrite = {};
protoWrite.__proto__ = 'ignored';             // silently not stored
const protoKeys = Object.keys(protoWrite);
const mapStoresIt = new Map().set('__proto__', 'stored').get('__proto__');

const bare = Object.create(null);
const bareBefore = ['toString' in bare, typeof bare.hasOwnProperty];
bare.toString = 'now just data';              // a key like any other here
const bareLookup = [...bareBefore, bare.toString];

const counts = [Object.keys({ a: 1, b: 2 }).length, new Map([['a', 1], ['b', 2]]).size];

console.log('inherited :', inheritedHit, '| a Map:', mapMiss);
console.log('key types :', stringifiedKey);
console.log('collision :', collisions);
console.log('shadowed  :', brokenCheck, '| Object.hasOwn:', properCheck);
console.log('__proto__ :', protoKeys, '| a Map:', mapStoresIt);
console.log('null proto:', bareLookup, '| counts:', counts);

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 empty object already answers yes for keys you never set', inheritedHit, [true, 'function']);
test('an empty Map has nothing in it whatsoever', mapMiss, false);
test('an object key is converted to a string on the way in', stringifiedKey, [['1'], true]);
test('so two different objects become one key, and any third object reads it', collisions, [['[object Object]'], 'second']);
test('a key called hasOwnProperty breaks the usual own-key check', brokenCheck, 'TypeError');
test('while Object.hasOwn cannot be shadowed', properCheck, true);
test('assigning __proto__ stores nothing, where a Map simply stores it', [protoKeys, mapStoresIt], [[], 'stored']);
test('Object.create(null) drops the inherited keys, and the helper methods with them', bareLookup, [false, 'undefined', 'now just data']);
test('size is a property on a Map and a computation on an object', counts, [2, 2]);

Every one of these is a real bug someone has shipped. A user called constructor passes an if (users[name]) check that should have failed. An id read from JSON as the number 1 and later as the string '1' hit the same slot, which is lucky until it is not. A cache keyed by request objects collapses into one entry called [object Object], so every lookup returns the last thing written. And obj[userInput] = value with userInput of '__proto__' is the shape of a real class of vulnerability.

Use a Map when the keys are data: arbitrary key types, no prototype, insertion order, a size, and cheap delete. Use an object when the keys are known at authoring time, which is what objects are actually for. Object.create(null) is the middle option — no inherited keys at all — at the cost of losing toString, hasOwnProperty and friends, so it prints awkwardly in a debugger.

A WeakMap is the same idea with three deliberate restrictions: keys must be objects, they are held weakly so an entry vanishes when nothing else references its key, and it is neither iterable nor countable — no size, no keys(). That is the point. It is for attaching data to objects you do not own, such as metadata for a DOM node, without keeping them alive for ever. If you can list what is in it, you can keep it alive, so the API refuses to let you.

63 Returning a function: a counter and once

fair

Write makeCounter(start, step) that returns an object of small functions closing over a private count, and prove two counters do not share it. Then write once(fn) so that a charge can only be taken one time however often it is called.

Stuck? Three hints, smallest first
  1. 1. The variable declared in the outer function is the private state. The returned functions are the only way to reach it.
  2. 2. Every call to the factory makes a new variable, so two counters are genuinely separate.
  3. 3. once needs two pieces of state: whether it has run, and what it returned that time.

The answer

const makeCounter = (start = 0, step = 1) => {
  let count = start;                          // private: no other way in
  return {
    next: () => (count += step),
    peek: () => count,
    reset: () => { count = start; return count; },
  };
};

const once = (fn) => {
  let called = false;
  let result;
  return (...args) => {
    if (called) return result;
    called = true;
    result = fn(...args);
    return result;
  };
};

let charges = 0;
const charge = (kobo) => { charges++; return `charged ${kobo}`; };
const chargeOnce = once(charge);

const ticket = makeCounter(100);
const other = makeCounter(100);
const threeInARow = [ticket.next(), ticket.next(), ticket.next()];
const otherUntouched = other.peek();
const afterReset = [ticket.reset(), ticket.next()];
const inFives = (() => {
  const c = makeCounter(0, 5);
  return [c.next(), c.next()];
})();

const results = [chargeOnce(500), chargeOnce(999), chargeOnce(1)];
const exposed = Object.keys(ticket);
const shared = ticket.next === other.next;

console.log('three pulls :', threeInARow, '| the other counter:', otherUntouched);
console.log('reset       :', afterReset, '| step of five:', inFives);
console.log('once        :', results, '| the wrapped function ran', charges, 'time(s)');
console.log('what is public:', exposed, '| count reachable?', ticket.count);

Output


      
9 checks that prove this answer

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

9 checks

test('each call advances the captured count', threeInARow, [101, 102, 103]);
test('a second counter has a count of its own', otherUntouched, 100);
test('reset puts it back to the start', afterReset, [100, 101]);
test('the step is captured just as the start is', inFives, [5, 10]);
test('once returns the first result to every later caller', results, ['charged 500', 'charged 500', 'charged 500']);
test('so the side effect happened exactly once', charges, 1);
test('and the later arguments were never even looked at', results[1], 'charged 500');
test('only the functions are public', exposed, ['next', 'peek', 'reset']);
test('the count is nowhere on the object, and not shared between counters', [ticket.count === undefined, shared], [true, false]);

A closure is a function plus the variables it was born next to. count outlives makeCounter because next, peek and reset still refer to it, and nothing else in the program can name it — privacy with no #, no WeakMap and no convention. The price is per-instance functions, the same cost as the arrow field in exercise 50, which is why a class with #count is the better choice when there will be thousands of counters.

once is the smallest useful higher-order function: it takes a function and returns a function, and its two closed-over variables are the whole implementation. Two details in the assertions are deliberate. It caches the result, so later callers get the first answer rather than undefined. And it ignores later arguments completely, which is a real trap — chargeOnce(999) looks like it should charge 999 and quietly does nothing at all. If that would be a bug, throw on the second call instead of returning the cached value.

64 memoise, and the key that collides

hard

Write memoise(fn) that caches on a JSON.stringify of the arguments, and prove it saves a call. Then find its two failures: values that stringify identically share a cache entry, and two objects with the same contents in a different key order get separate entries. Fix the single-argument case with a Map keyed by the argument itself.

Stuck? Three hints, smallest first
  1. 1. Cache in a Map and use cache.has(key), not a truthy check, so a cached undefined or 0 still counts as a hit.
  2. 2. JSON.stringify([undefined]), JSON.stringify([null]) and JSON.stringify([NaN]) are all "[null]".
  3. 3. For one argument, skip stringifying: a Map compares keys with SameValueZero, so NaN, null and undefined stay separate.

The answer

const memoise = (fn) => {
  const cache = new Map();
  const memo = (...args) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const value = fn(...args);
    cache.set(key, value);
    return value;
  };
  memo.keys = () => [...cache.keys()];
  return memo;
};

const memoiseByArg = (fn) => {
  const cache = new Map();
  return (arg) => {
    if (cache.has(arg)) return cache.get(arg);
    const value = fn(arg);
    cache.set(arg, value);
    return value;
  };
};

let fareCalls = 0;
const slowFare = (km, area) => {
  fareCalls++;
  return km * (area === 'Lekki' ? 2500 : 1000);
};
const fare = memoise(slowFare);
const first = fare(10, 'Lekki');
const repeated = fare(10, 'Lekki');
const different = fare(10, 'Ikeja');
const fareCallsAfter = fareCalls;

let kindCalls = 0;
const kindOf = (value) => { kindCalls++; return typeof value; };
const memoKind = memoise(kindOf);
const forUndefined = memoKind(undefined);
const forNull = memoKind(null);               // a HIT on the same key
const forNaN = memoKind(NaN);                 // another one
const kindCallsAfter = kindCalls;
const cachedKeys = memoKind.keys();

let areaCalls = 0;
const areaOf = (o) => { areaCalls++; return `${o.lga}/${o.zone}`; };
const memoArea = memoise(areaOf);
const inOrder = memoArea({ lga: 'Eti-Osa', zone: 'B' });
const reversed = memoArea({ zone: 'B', lga: 'Eti-Osa' });
const areaCallsAfter = areaCalls;

let exactCalls = 0;
const exact = memoiseByArg((value) => { exactCalls++; return String(value); });
const nanTwice = [exact(NaN), exact(NaN)];
const callsAfterNaN = exactCalls;
const nullAndUndefined = [exact(undefined), exact(null)];
const callsAtTheEnd = exactCalls;

console.log('fare      :', first, repeated, different, '| calls:', fareCallsAfter);
console.log('collision :', [forUndefined, forNull, forNaN], '| calls:', kindCallsAfter, '| keys:', cachedKeys);
console.log('key order :', inOrder, reversed, '| calls:', areaCallsAfter);
console.log('by arg    :', nanTwice, nullAndUndefined, '| calls:', callsAtTheEnd);

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 repeat call comes back from the cache', [first, repeated], [25000, 25000]);
test('so the wrapped function ran once per distinct argument list', [different, fareCallsAfter], [10000, 2]);
test('but undefined, null and NaN all stringify to null, so they collide', [forUndefined, forNull, forNaN], ['undefined', 'undefined', 'undefined']);
test('one call, one cache entry, and two wrong answers', [kindCallsAfter, cachedKeys], [1, ['[null]']]);
test('the other way round, one meaning in two key orders gets two entries', [inOrder, reversed, areaCallsAfter], ['Eti-Osa/B', 'Eti-Osa/B', 2]);
test('keying a Map by the argument itself is exact, so NaN hits', [nanTwice, callsAfterNaN], [['NaN', 'NaN'], 1]);
test('and undefined and null stay apart', nullAndUndefined, ['undefined', 'null']);
test('which came to one call per genuinely distinct argument', callsAtTheEnd, 3);
test('has, not a truthy test, is what makes a cached falsy value a hit', memoiseByArg(() => 0)(1), 0);

Memoising is a trade of memory for time, and it is only correct for a pure function: cache slowFare and you win, cache anything that reads the clock or a database and you have frozen a stale answer for ever. The cache here also never evicts, so a long-lived memo over many arguments is a leak with a friendly name.

The key function is where the real bugs live, and JSON.stringify fails in both directions at once. It collides: undefined, null and NaN all become null, so three different calls share one entry and two of them get the wrong answer with no error. It also misses: object key order changes the string, so the same information passed twice is cached twice, and a Map, Set, Date or function argument stringifies to something useless. For one argument, key the Map on the argument itself — exact, no serialisation, and object arguments compare by identity, which is honest. For several, build a nested Map or accept the trade knowingly.

The last assertion is the small one that bites in practice: writing if (cache[key]) return cache[key] means a cached 0, '', false, null or undefined is recomputed every single time, which is a memo that quietly does nothing for exactly the functions you would most want to cache.

65 Partial application, curry, compose and pipe

hard

Fix the leading arguments of a three-argument fare function two ways, with your own partial and with bind. Write curry that keeps collecting until it has enough arguments, then compose and pipe, and show that the two run the same steps in opposite directions.

Stuck? Three hints, smallest first
  1. 1. partial is one line: capture the fixed arguments with rest, spread them in front of the rest at call time.
  2. 2. curry needs to know when to stop collecting, and the only thing it has to go on is fn.length.
  3. 3. pipe is reduce over the functions; compose is reduceRight.

The answer

const fare = (base, perKm, km) => base + perKm * km;

const partial = (fn, ...fixed) => (...rest) => fn(...fixed, ...rest);
const lagosFare = partial(fare, 25000, 1500);
const viaBind = fare.bind(null, 25000, 1500);

const curry = (fn) => {
  const collect = (got) => (...args) => {
    const all = [...got, ...args];
    return all.length >= fn.length ? fn(...all) : collect(all);
  };
  return collect([]);
};
const curried = curry(fare);

const pipe = (...fns) => (value) => fns.reduce((acc, fn) => fn(acc), value);
const compose = (...fns) => (value) => fns.reduceRight((acc, fn) => fn(acc), value);

const toKobo = (naira) => naira * 100;
const addVat = (kobo) => Math.round(kobo * 1.075);
const label = (kobo) => `₦${kobo / 100}`;

const priced = pipe(toKobo, addVat, label);
const sameThing = compose(label, addVat, toKobo);
const directions = [pipe((n) => n + 1, (n) => n * 2)(5), compose((n) => n + 1, (n) => n * 2)(5)];

const variadic = (...args) => args.length;
const brokenCurry = curry(variadic);
const collectedNothing = brokenCurry();
const arities = [fare.length, viaBind.length, lagosFare.length];

console.log('partial   :', lagosFare(10), '| bind:', viaBind(10));
console.log('curried   :', curried(25000)(1500)(10), curried(25000, 1500)(10));
console.log('pipe      :', priced(400), '| compose:', sameThing(400));
console.log('directions:', directions);
console.log('arities   :', arities, '| curry of a variadic function:', collectedNothing);

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('partial fixes the leading arguments', lagosFare(10), 40000);
test('bind does the same, with null standing in for this', viaBind(10), 40000);
test('a curried function takes them one at a time', curried(25000)(1500)(10), 40000);
test('or in any grouping, until it has enough', [curried(25000, 1500)(10), curried(25000)(1500, 10)], [40000, 40000]);
test('pipe reads left to right', priced(400), '₦430');
test('compose is the same three steps written right to left', sameThing(400), '₦430');
test('and the direction genuinely changes the answer', directions, [12, 11]);
test('bind keeps the remaining arity; a rest-parameter wrapper reports zero', arities, [3, 1, 0]);
test('so currying a variadic function calls it at once, having collected nothing', [brokenCurry.length, collectedNothing], [0, 0]);

Partial application and currying are not the same thing. partial fixes some arguments and returns something you call once with the rest. curry returns a chain: one argument at a time, or several, until the count is reached. Both exist to turn a general function into a specific one — lagosFare is a fare function with the tariff already in it — which is exactly what you want when passing a function to map.

The arity assertion is where hand-rolled currying breaks. fn.length counts parameters before the first default or rest, so it is 0 for (...args) => and for anything already wrapped in a rest-parameter helper. Curry a variadic function and it calls immediately with nothing; curry the result of your own partial and the same thing happens. Note the contrast in that assertion: bind reports the arity that is genuinely left, 3 - 2 = 1, because the engine tracks it; your wrapper cannot.

pipe and compose differ only in reading order, and pipe is usually the kinder one because it matches the order things happen. Both are single-value chains: to thread more than one value, make the first function take the several arguments, or pass an object through.

66 Reading an order with defaults, new names and a rest

easy

Write one function that pulls an order apart in its parameter list: rename qty and price, default the ones that are missing, reach into a buyer that may not be there, and sweep every key you did not name into a rest object. Then show what a null does to a default.

Stuck? Three hints, smallest first
  1. 1. A default in a pattern fires only for undefined. null is a value, so it is kept.
  2. 2. Rename and default at once with qty: quantity = 1, and give the nested pattern its own = {} so a missing buyer does not throw.
  3. 3. The whole parameter needs = {} too, otherwise calling with no argument at all destructures undefined.

The answer

const readOrder = ({
  item,
  qty: quantity = 1,
  price: kobo = 0,
  buyer: { name: buyerName = 'walk-in', phone = null } = {},
  ...extras
} = {}) => ({ item, quantity, kobo, buyerName, phone, extras });

const full = readOrder({
  item: 'garri',
  qty: 3,
  price: 90000,
  buyer: { name: 'Adaeze', phone: '0803' },
  market: 'Ikeja',
  note: 'coarse',
});
const bare = readOrder({ item: 'yam' });
const nothing = readOrder();

// A default answers "was it undefined", not "was it empty".
const withNull = readOrder({ item: 'rice', qty: null }).quantity;
const withZero = readOrder({ item: 'rice', qty: 0 }).quantity;

// Destructuring undefined is a TypeError, which is why the = {} is there.
let cracked;
try {
  const { item } = undefined;
  cracked = item;
} catch (error) {
  cracked = error.constructor.name;
}

// Renaming moves the binding: the source key never becomes a variable.
const sourceKeyInScope = typeof qty;

console.log('full   :', full);
console.log('bare   :', bare);
console.log('nothing:', nothing.item, nothing.quantity, nothing.buyerName);
console.log('null vs zero:', withNull, withZero, '| undefined:', cracked);

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 named key lands in its new name', [full.item, full.quantity, full.kobo, full.buyerName], ['garri', 3, 90000, 'Adaeze']);
test('the rest holds only the keys the pattern did not name', full.extras, { market: 'Ikeja', note: 'coarse' });
test('defaults fill the gaps in a bare order', [bare.quantity, bare.kobo, bare.buyerName, bare.phone], [1, 0, 'walk-in', null]);
test('and = {} on the parameter lets it be called with nothing at all', [nothing.item ?? 'none', nothing.quantity, nothing.buyerName], ['none', 1, 'walk-in']);
test('a default fires for undefined only, so null survives and 0 survives', [withNull, withZero], [null, 0]);
test('without that default, destructuring undefined throws', cracked, 'TypeError');
test('renaming does not leave the old name behind', sourceKeyInScope, 'undefined');
test('a nested pattern with its own default survives a missing branch', readOrder({ item: 'ewa' }).phone, null);

A parameter pattern is documentation the engine enforces. Everything a caller may leave out has its default written where you read it, and everything you did not plan for is still there in extras rather than silently dropped.

The trap is the word "missing". A pattern default is triggered by undefined and nothing else, so { qty: null } gives you null, not 1, and a row that came back from a database with real nulls in it walks straight past every default you wrote. If you want null handled too, follow the destructure with ??, which treats null and undefined alike.

The two = {} defaults are load-bearing in different places. The one on the parameter makes readOrder() legal; the one after buyer: makes an order without a buyer legal. Leave either out and you get the same TypeError the try above catches, because reading a property of undefined is exactly what a pattern does.

67 Array patterns: swap, skip, rest and a default that costs something

fair

Use array patterns for the four things they are good at: swapping two variables with no temporary, skipping elements you do not want, collecting the tail with a rest element, and defaulting a missing element from a function call. Then count how many times that default function actually ran.

Stuck? Three hints, smallest first
  1. 1. A hole in the pattern skips a position: const [, second] = list.
  2. 2. A default in a pattern is an expression, and expressions in defaults are lazy: it is only evaluated when that position is undefined.
  3. 3. A rest element must come last, and it is always an array, even when there is nothing left to put in it.

The answer

let first = 'Ikeja';
let second = 'Yaba';
[first, second] = [second, first];

const readings = [12, 45, 78, 91, 103];
const [, secondReading, , ...tail] = readings;

let lookups = 0;
const fallbackRate = () => {
  lookups++;
  return 1500;
};
const [cheap = fallbackRate(), dear = fallbackRate()] = [900];

const rows = [['Adaeze', 3], ['Bola', 1]];
const named = rows.map(([who, howMany]) => `${who} x${howMany}`);

const [[a, b], [c]] = [[1, 2], [3, 4]];
const [x, y, z] = 'NG';
const [only, missing] = [7];
const [head, ...nothingLeft] = [7];

const tally = new Map([['garri', 2], ['rice', 5]]);
const asLines = [];
for (const [good, count] of tally) asLines.push(`${good}=${count}`);

console.log('swap    :', first, second);
console.log('skipped :', secondReading, tail);
console.log('lazy    :', cheap, dear, '| fallback ran', lookups, 'time(s)');
console.log('nested  :', a, b, c, '| string:', x, y, z);

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 swap needs no temporary, because the right side is built first', [first, second], ['Yaba', 'Ikeja']);
test('holes skip positions and the rest element takes everything after', [secondReading, tail], [45, [91, 103]]);
test('the default only ran for the position that was missing', [cheap, dear, lookups], [900, 1500, 1]);
test('a pattern in a parameter list unpacks each row', named, ['Adaeze x3', 'Bola x1']);
test('patterns nest, and a string is iterable so it destructures too', [a, b, c, x, y, z === undefined], [1, 2, 3, 'N', 'G', true]);
test('asking for more than there is gives undefined, not an error', [only, missing === undefined], [7, true]);
test('but a rest element is an array even when it is empty', [head, nothingLeft], [7, []]);
test('and a Map entry is a two-element array, so the for...of head can unpack it', asLines, ['garri=2', 'rice=5']);

The swap works because the right-hand side is fully evaluated into a new array before any binding on the left is written. That is also why it needs no temporary and why it generalises: [a, b, c] = [c, a, b] rotates three values in one line.

Watch the semicolon. A line that starts with [ continues the line above it, so let a = 1 followed by [a, b] = [b, a] with no semicolon parses as let a = 1[a, b] and fails in a way that reads like nonsense. This is the one place in ordinary code where leaving a semicolon off genuinely breaks the program.

Two details worth keeping. A missing element gives undefined rather than throwing, so a pattern is a soft read and you check afterwards if it mattered. And defaults are lazy expressions, so the fallback lookup above ran once, not twice — which is what makes const [rate = expensiveLookup()] = row safe to write.

68 Rest and spread, in a call and in a literal

easy

Write a variadic total with a rest parameter, then use spread to push a whole array into a call, to merge two objects, and to turn a Set and a string into arrays. Show what Math.max does with an empty spread, and what object spread copies that JSON then throws away.

Stuck? Three hints, smallest first
  1. 1. Rest gathers, spread scatters. Same three dots, opposite directions, told apart by where they sit.
  2. 2. Math.max() with no arguments is -Infinity, so Math.max(...[]) is too.
  3. 3. Object spread copies own enumerable properties, symbol keys included; array spread needs a real iterable, so [...null] throws while { ...null } is fine.

The answer

const total = (...amounts) => amounts.reduce((sum, n) => sum + n, 0);
const takeThrough = (label, ...rest) => `${label}:${rest.length}`;

const prices = [45000, 12000, 3000];
const sumOfPrices = total(...prices);
const withExtra = total(...prices, 500);
const forwarded = takeThrough('cart', ...prices);
const shifted = takeThrough(...prices);

const biggest = Math.max(...prices);
const ofNothing = Math.max(...[]);
const emptyIsInfinity = ofNothing === -Infinity;

const base = { market: 'Ikeja', currency: 'NGN', open: true };
const patch = { open: false, note: 'renovating' };
const merged = { ...base, ...patch };
const guarded = { ...patch, ...base };

const tag = Symbol('internal');
const spreadKeepsSymbols = { ...{ [tag]: 'yes', shown: 1 } };
const symbolSurvived = spreadKeepsSymbols[tag];
const jsonDropsIt = JSON.stringify(spreadKeepsSymbols);

const seen = new Set(['garri', 'rice', 'garri']);
const fromSet = [...seen];
const fromString = [...'NGN'];
const spreadNullObject = { ...null };
let spreadNullArray;
try {
  spreadNullArray = [...null];
} catch (error) {
  spreadNullArray = error.constructor.name;
}

console.log('total  :', sumOfPrices, withExtra, forwarded, shifted);
console.log('max    :', biggest, '| of nothing:', ofNothing);
console.log('merged :', merged, '| other order:', guarded.open);
console.log('spread :', fromSet, fromString, spreadNullObject, spreadNullArray);

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 rest parameter gathers however many arguments arrive', [sumOfPrices, withExtra], [60000, 60500]);
test('the named parameter takes the first argument, rest takes whatever is left', [forwarded, shifted], ['cart:3', '45000:2']);
test('spread scatters an array into a call that never asked for one', biggest, 45000);
test('an empty spread leaves Math.max with no arguments at all', emptyIsInfinity, true);
test('in a merge the later spread wins, so order is the whole decision', [merged.open, merged.note, guarded.open], [false, 'renovating', true]);
test('object spread copies symbol keys; JSON.stringify cannot see them', [symbolSurvived, jsonDropsIt], ['yes', '{"shown":1}']);
test('spread reads any iterable, and a Set has already deduplicated', [fromSet, fromString], [['garri', 'rice'], ['N', 'G', 'N']]);
test('object spread shrugs at null; array spread demands an iterable', [spreadNullObject, spreadNullArray], [{}, 'TypeError']);

Rest and spread are the same token doing opposite jobs. In a parameter list or on the left of an assignment it gathers; in a call or a literal it scatters. Rest gives you a real array, which arguments never was, so map and reduce work on it directly.

Math.max(...list) is the classic one-liner and the classic bug: an empty list is not an error, it is -Infinity, and that number goes on to poison whatever arithmetic it lands in. Guard the length, or use reduce with a real starting value.

Object spread and JSON disagree about what a copy is. Spread takes own enumerable properties including symbol keys, but it does not run setters on the target, does not copy the prototype, and does not descend: nested objects are shared by reference. JSON.stringify then quietly drops symbols, functions and undefined, which is why a round trip through JSON is a different operation from a spread, not a deeper version of it.

69 entries and fromEntries, there and back

fair

Convert a price list in kobo to naira by going out to entries, mapping, and coming back with Object.fromEntries. Do the same for a filter. Then show three things about the round trip: what happens to duplicate keys, what a Map gives you, and what integer-like keys do to the order.

Stuck? Three hints, smallest first
  1. 1. Object.entries(o).map(([k, v]) => [k, f(v)]) then Object.fromEntries is the whole of mapValues.
  2. 2. fromEntries takes any iterable of pairs, so a Map goes straight in.
  3. 3. Keys that look like non-negative integers are stored in ascending numeric order, before every string key, whatever order you wrote them in.

The answer

const kobo = { garri: 90000, rice: 450000, palmOil: 120000 };

const mapValues = (o, fn) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, fn(v, k)]));
const filterValues = (o, keep) => Object.fromEntries(Object.entries(o).filter(([k, v]) => keep(v, k)));

const naira = mapValues(kobo, (k) => k / 100);
const dear = filterValues(kobo, (k) => k >= 120000);
const shouted = Object.fromEntries(Object.entries(kobo).map(([k, v]) => [k.toUpperCase(), v]));
const roundTripped = same(Object.fromEntries(Object.entries(kobo)), kobo);

const stock = new Map([['garri', 12], ['rice', 4]]);
const fromMap = Object.fromEntries(stock);
const backToMap = [...new Map(Object.entries(fromMap))];

const duplicated = Object.fromEntries([['lga', 'Ikeja'], ['lga', 'Eti-Osa']]);
const numericOrder = Object.entries({ 10: 'ten', 2: 'two', zone: 'B', 1: 'one' });
const arrayEntries = Object.entries(['a', 'b']);
const valuesAndKeys = [Object.keys(kobo).length, Object.values(kobo).reduce((a, b) => a + b, 0)];

console.log('naira  :', naira);
console.log('filter :', dear, '| shouted:', shouted);
console.log('map    :', fromMap, backToMap);
console.log('dupes  :', duplicated, '| order:', numericOrder.map(([k]) => k));

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('entries out, map, fromEntries back is mapValues in one line', naira, { garri: 900, rice: 4500, palmOil: 1200 });
test('the same shape filters, because a pair carries the key too', dear, { rice: 450000, palmOil: 120000 });
test('and the key is just as mappable as the value', Object.keys(shouted), ['GARRI', 'RICE', 'PALMOIL']);
test('a round trip with no change gives back an equal object', roundTripped, true);
test('fromEntries eats any iterable of pairs, so a Map goes straight in', [fromMap, backToMap], [{ garri: 12, rice: 4 }, [['garri', 12], ['rice', 4]]]);
test('a repeated key is not an error: the last pair wins, silently', duplicated, { lga: 'Eti-Osa' });
test('integer-like keys come out in numeric order, ahead of every string key', numericOrder.map(([k]) => k), ['1', '2', '10', 'zone']);
test('entries on an array gives string indices, which fromEntries keeps as strings', [arrayEntries, Object.fromEntries(arrayEntries)], [[['0', 'a'], ['1', 'b']], { 0: 'a', 1: 'b' }]);
test('keys and values are the same walk, projected differently', valuesAndKeys, [3, 660000]);

Object.entries and Object.fromEntries turn an object into something array methods can work on and then turn it back. That pairing replaces most hand-written for...in loops with an accumulator: mapValues, filterValues, key renaming and inversion are all the same three lines with a different callback.

Two facts to hold on to. The round trip is lossy in the ways spread is not: only own enumerable string keys make it through, so symbol keys, non-enumerable properties and the prototype are all left behind. And fromEntries has no opinion about duplicates — the last pair for a key wins with no warning, which is either exactly the merge you wanted or a bug you will not see until two rows share an id.

The ordering assertion is the one that catches people building objects keyed by number. Integer-like keys are held in ascending numeric order and listed before any string key, so an object keyed 10, 2, 1 reads back 1, 2, 10. If insertion order is part of your meaning, use a Map, where a key is whatever you put in and the order is the order you put it.

70 Optional chaining, and the place ?? beats ||

fair

Read values out of a half-filled settings object without a single && guard: ?. for a property, ?.[] for a computed key, ?.() for a method that may not exist. Then supply fallbacks with ?? and show the two cases where || gives the wrong answer.

Stuck? Three hints, smallest first
  1. 1. ?. stops for null and undefined only, and when it stops the whole rest of the chain is skipped, not just the next step.
  2. 2. || replaces anything falsy, which includes 0, '', NaN and false. ?? replaces only null and undefined.
  3. 3. A short-circuited ?.() never evaluates its arguments, so nothing inside the parentheses runs.

The answer

const stall = {
  owner: { name: 'Adaeze', phone: null },
  display: { pageSize: 0, label: '', printable: false },
  tools: { print: (what) => `printing ${what}` },
  address: null,
};

const city = stall.address?.city;
const deeper = stall.address?.city.lga.zone;   // the WHOLE chain is skipped
const dynamic = stall.display?.['pageSize'];
const printed = stall.tools.print?.('receipt');
const emailed = stall.tools.email?.('receipt');

let sideEffects = 0;
const argument = () => {
  sideEffects++;
  return 'x';
};
const skipped = stall.tools.email?.(argument());
const ran = stall.tools.print?.(argument());

const pageSize = stall.display.pageSize ?? 20;
const pageSizeWrong = stall.display.pageSize || 20;
const label = stall.display.label ?? 'Ikeja stall';
const labelWrong = stall.display.label || 'Ikeja stall';
const phone = stall.owner.phone ?? 'none';
const printable = stall.display.printable ?? true;

let mixed;
try {
  mixed = new Function('return null ?? 1 || 2;')();
} catch (error) {
  mixed = error.constructor.name;
}

let unguarded;
try {
  unguarded = stall.address.city?.name;   // ?. after the null, not before it
} catch (error) {
  unguarded = error.constructor.name;
}

console.log('chain  :', city, deeper, dynamic);
console.log('calls  :', printed, emailed, '| side effects:', sideEffects);
console.log('?? vs ||:', [pageSize, pageSizeWrong], [label, labelWrong]);
console.log('mixed  :', mixed, '| unguarded:', unguarded);

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 missing branch reads as undefined instead of throwing', [city, dynamic], [undefined, 0]);
test('and when ?. short-circuits it abandons every step after it', deeper, undefined);
test('?.() calls a method only if it is there', [printed, emailed], ['printing receipt', undefined]);
test('a short-circuited call never even evaluates its arguments', [skipped, ran, sideEffects], [undefined, 'printing x', 1]);
test('?? keeps a real 0 and a real empty string; || throws both away', [pageSize, pageSizeWrong, label, labelWrong], [0, 20, '', 'Ikeja stall']);
test('?? does replace null, and leaves false alone', [phone, printable], ['none', false]);
test('mixing ?? and || without parentheses is a syntax error, on purpose', mixed, 'SyntaxError');
test('?. guards the step it is written on, not the one before it', unguarded, 'TypeError');

?. replaces the a && a.b && a.b.c ladder, and it short-circuits the entire chain rather than one link: once stall.address?. sees null, the .city.lga.zone that follows is never evaluated at all. That is why the long chain above is safe with only one question mark in it.

It guards the step it sits on. stall.address.city?.name still throws, because the crash happens at .city on a null, before the ?. is reached. Put the mark where the value might be missing, and only there — a chain peppered with ?. at every step hides real bugs behind quiet undefineds.

?? exists because || answers the wrong question. A page size of 0, an empty label, a temperature of 0 and a false flag are all legitimate values that || silently overwrites with your default. ?? asks only "was this absent". The language refuses to let you mix ?? with && or || unparenthesised, precisely because the precedence would be a coin toss for the reader.

71 The logical assignments, and the write that never happens

fair

Apply defaults to a settings object with ??=, ||= and &&=, and show how differently the three treat a 0 and an empty string. Then put a counting setter behind a property and prove that a short-circuited logical assignment does not write at all — which x = x || y always does.

Stuck? Three hints, smallest first
  1. 1. a ??= b is a ?? (a = b): the assignment is inside the short circuit, not outside it.
  2. 2. &&= only assigns when the current value is truthy, so it updates what is there and never creates what is missing.
  3. 3. To see whether a write happened, give the object a setter that counts.

The answer

const applyDefaults = (o) => {
  o.pageSize ??= 20;          // absent only
  o.label ||= 'Ikeja stall';  // anything falsy
  o.currency ??= 'NGN';
  o.note &&= o.note.trim();   // present only
  return o;
};

const partial = applyDefaults({ pageSize: 0, label: '', note: '  ripe  ' });
const empty = applyDefaults({});
const noteWasNotCreated = 'note' in empty;

let writes = 0;
let reads = 0;
const account = {
  _kobo: 500000,
  get kobo() {
    reads++;
    return this._kobo;
  },
  set kobo(value) {
    writes++;
    this._kobo = value;
  },
};

account.kobo ??= 1;                  // 500000 is not nullish, so NO write
account.kobo ||= 1;                  // 500000 is truthy, so NO write
const writesAfterShortCircuit = writes;
account.kobo &&= account.kobo + 100; // truthy, so this one does write
const writesAfterUpdate = writes;

const always = { _n: 0, get n() { return this._n; }, set n(v) { writes++; this._n = v; } };
always.n = always.n || 7;            // the old spelling: always writes
const writesAfterOldSpelling = writes;

const flags = { verbose: false, retries: 0 };
flags.verbose ||= true;
flags.retries ??= 5;

console.log('partial:', partial);
console.log('empty  :', empty, '| note created:', noteWasNotCreated);
console.log('account:', account._kobo, '| writes:', writes, '| reads:', reads);
console.log('flags  :', flags);

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('??= keeps a real 0, ||= replaces an empty string', [partial.pageSize, partial.label], [0, 'Ikeja stall']);
test('&&= updates a value that is already there', partial.note, 'ripe');
test('and fills in what was absent', [empty.pageSize, empty.label, empty.currency], [20, 'Ikeja stall', 'NGN']);
test('but &&= on a missing key does not even create it', noteWasNotCreated, false);
test('a short-circuited ??= or ||= performs no assignment at all', writesAfterShortCircuit, 0);
test('&&= on a truthy value does assign', [writesAfterUpdate, account._kobo], [1, 500100]);
test('while x = x || y writes every time, setter and all', [writesAfterOldSpelling, always._n], [2, 7]);
test('so ||= clobbers false and ??= respects 0, which is usually backwards from what you want', [flags.verbose, flags.retries], [true, 0]);

The three logical assignments are short-circuiting, and the short circuit covers the assignment. a ||= b is a || (a = b), not a = a || b. With a plain data property you cannot tell the difference; with a setter, a Proxy, a reactive store or a DOM property you certainly can, because the older spelling fires the setter every single time and the newer one only fires it when the value actually changes.

Pick by the question you are asking. ??= means "if it was never supplied". ||= means "if it is empty or zero or false". &&= means "if there is something here, transform it", which is the neat way to trim, round or normalise an optional field without an if.

The last assertion is the whole trap in one line: flags.verbose ||= true flips a deliberate false to true, because false is falsy and ||= cannot tell a chosen false from a missing one. For booleans and numbers, ??= is almost always the one you meant.

72 Array.from, with the map function nobody uses

easy

Build a fare table from nothing with Array.from({ length: n }, fn). Then use the same call to copy a Set, split a string into code points, read an array-like that is not iterable, and show why new Array(3).map(...) cannot do the job.

Stuck? Three hints, smallest first
  1. 1. Array.from takes a second argument: a map function, called with (value, index).
  2. 2. new Array(3) has a length of three and no elements at all, and every array method skips holes.
  3. 3. An object with a length is enough for Array.from; being iterable is not required.

The answer

const fares = Array.from({ length: 5 }, (_, i) => 250 + i * 150);
const seats = Array.from({ length: 3 }, (_, i) => `A${i + 1}`);

const holes = new Array(3);
const mappedHoles = holes.map((_, i) => i);
const holesStayedHoles = mappedHoles.every((v) => v === undefined);
const filled = Array.from(holes, (_, i) => i);
const blank = Array.from({ length: 2 });
const blankIsUndefined = blank.every((v) => v === undefined);

const repeated = ['garri', 'rice', 'garri', 'beans'];
const unique = Array.from(new Set(repeated));
const shouted = Array.from('Ikeja', (c) => c.toUpperCase());

const flag = '🇳🇬';
const unitsAgainstPoints = [flag.length, Array.from(flag).length];

const stock = new Map([['garri', 12], ['rice', 4]]);
const asPairs = Array.from(stock);
const labels = Array.from(stock, ([good, count]) => `${good}:${count}`);

const arrayLike = { length: 3, 0: 'Lagos', 2: 'Kano' };
const fromArrayLike = Array.from(arrayLike, (v) => v ?? 'gap');

console.log('fares  :', fares, seats);
console.log('holes  :', mappedHoles, '| from:', filled);
console.log('unique :', unique, '| shouted:', shouted);
console.log('map    :', asPairs, labels, '| array-like:', fromArrayLike);

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('length plus a map function builds a table with no source array', fares, [250, 400, 550, 700, 850]);
test('the second argument gets the index, which is usually all you need', seats, ['A1', 'A2', 'A3']);
test('map cannot fill holes: it skips every one of them', [holesStayedHoles, mappedHoles.length], [true, 3]);
test('Array.from visits all three positions, holes included', [filled, blankIsUndefined], [[0, 1, 2], true]);
test('it copies any iterable, so a Set is how you deduplicate', unique, ['garri', 'rice', 'beans']);
test('a string is iterable by code point, not by UTF-16 unit', unitsAgainstPoints, [4, 2]);
test('and mapping while copying saves building the array twice', [shouted.join(''), labels], ['IKEJA', ['garri:12', 'rice:4']]);
test('an array-like needs only a length; missing indices arrive as undefined', fromArrayLike, ['Lagos', 'gap', 'Kano']);

Array.from(iterable, fn) is a copy and a map in one pass, so it never builds the intermediate array that [...thing].map(fn) does. With { length: n } as the source it becomes the range constructor JavaScript never shipped, which is where most of its use lives: Array.from({ length: 12 }, (_, i) => i + 1).

The hole story is the reason it exists. new Array(3) is length three with no elements, and map, forEach, filter and reduce all skip holes, so new Array(3).map((_, i) => i) returns three holes and looks broken. Array.from reads indices 0..length-1 directly, so every position is visited. Array(3).fill(0) is the other way out.

It also bridges two gaps that [...spread] cannot. Spread demands an iterable, so an array-like — a { length } object, or older DOM collections — goes through Array.from instead. And on a string it splits by code point, so a flag or an emoji stays whole where split('') and .length cut it in half.

73 flat, flatMap and the depth you forgot to pass

fair

Flatten a nested list of market takings with flat, then again with an explicit depth and with Infinity. Use flatMap to expand one order into several receipt lines and to filter and map in a single pass. Then show the two things flat does that surprise people: it removes holes, and it leaves strings alone.

Stuck? Three hints, smallest first
  1. 1. flat() defaults to depth 1, which is one level, not all of them.
  2. 2. flatMap(fn) is map(fn).flat() — depth one, always, no matter what you pass.
  3. 3. Returning [] from a flatMap callback contributes nothing, which is how one pass can both drop and expand.

The answer

const takings = [[4500, 12000], [8000], [[3000, 1000], 500]];

const oneLevel = takings.flat();
const twoLevels = takings.flat(2);
const allLevels = takings.flat(Infinity);
const totalOfAll = allLevels.reduce((a, b) => a + b, 0);

const orders = [
  { item: 'garri', qty: 2, kobo: 45000 },
  { item: 'rice', qty: 0, kobo: 450000 },
  { item: 'beans', qty: 3, kobo: 30000 },
];

const lines = orders.flatMap(({ item, qty }) => Array.from({ length: qty }, (_, i) => `${item} #${i + 1}`));
const priced = orders.flatMap(({ item, qty, kobo }) => (qty === 0 ? [] : [`${item}=${(qty * kobo) / 100}`]));
const withIndex = orders.flatMap(({ item }, i) => [i, item]);

const stillNested = [1, 2].flatMap((n) => [[n * 10]]);
const sparse = [1, , 3];
const sparseFlat = sparse.flat();
const strings = [['a'], 'bc', ['d']].flat();
const flatOfFlat = [[[1]]].flat().flat();

console.log('flat   :', oneLevel);
console.log('depths :', twoLevels, allLevels, totalOfAll);
console.log('lines  :', lines, '| priced:', priced);
console.log('sparse :', sparse.length, sparseFlat, '| strings:', strings);

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('flat with no argument goes exactly one level down', oneLevel, [4500, 12000, 8000, [3000, 1000], 500]);
test('a depth of 2 reaches the next one, and Infinity reaches the bottom', [twoLevels, allLevels], [[4500, 12000, 8000, 3000, 1000, 500], [4500, 12000, 8000, 3000, 1000, 500]]);
test('which is what makes a sum over ragged data a one-liner', totalOfAll, 29000);
test('flatMap expands one item into several', lines, ['garri #1', 'garri #2', 'beans #1', 'beans #2', 'beans #3']);
test('and returning an empty array drops an item, so filter and map fit in one pass', priced, ['garri=900', 'beans=900']);
test('the callback still gets the index', withIndex, [0, 'garri', 1, 'rice', 2, 'beans']);
test('flatMap flattens one level and only one, whatever you return', stillNested, [[10], [20]]);
test('flat drops holes, so the length changes even with nothing nested', [sparse.length, sparseFlat], [3, [1, 3]]);
test('a string is not flattened, and two flats are not the same as flat(2) on deeper data', [strings, flatOfFlat], [['a', 'bc', 'd'], [1]]);

flat's default depth of one is the whole trap. Data that came back three levels deep looks half-fixed after flat(), and the bug appears later as an array where a number was expected. If you genuinely do not know the shape, say flat(Infinity) and mean it.

flatMap is the useful one. Expanding an item into many, dropping an item by returning [], and mapping normally by returning a single-element array are all the same call, which turns a filter plus a map plus a concat into one traversal. It always flattens exactly one level, so a callback that returns nested arrays keeps the nesting.

The hole behaviour is worth remembering because it is the only cheap way to remove them: [1, , 3].flat() is [1, 3], length two, where the original had length three. And strings, though iterable, are never flattened — flat checks for arrays, not for iterables, so 'bc' stays a single item rather than becoming two characters.

74 at, and the negative index brackets cannot reach

easy

Read the last, second-to-last and first elements of a list with at, and compare each with what square brackets do. Then check four edge cases: a negative zero, a fractional index, an index past the end on a string, and what at gives you in the middle of a surrogate pair.

Stuck? Three hints, smallest first
  1. 1. list[-1] is not an index. It is a property lookup for the key "-1", which nothing set.
  2. 2. at counts a negative index back from length, and returns undefined when it lands outside the array.
  3. 3. charAt returns an empty string for an index that does not exist; at returns undefined.

The answer

const readings = [12, 45, 78, 91, 103];

const last = readings.at(-1);
const penultimate = readings.at(-2);
const firstOne = readings.at(0);
const bracketMinusOne = readings[-1];
const oldWay = readings[readings.length - 1];

const pastTheEnd = readings.at(5);
const wayBefore = readings.at(-9);
const negativeZero = readings.at(-0);
const fractional = readings.at(1.7);
const notANumber = readings.at('2');

const lga = 'Ikeja';
const lastLetter = lga.at(-1);
const charAtPastEnd = lga.charAt(99);
const atPastEnd = lga.at(99);

const flag = '🇳🇬';
const halfAPair = flag.at(-1);
const wholePoint = Array.from(flag).at(-1);
const pieceSizes = [halfAPair.length, wholePoint.length];

const arrayLike = { length: 2, 0: 'Lagos', 1: 'Kano' };
const borrowed = Array.prototype.at.call(arrayLike, -1);

console.log('at     :', last, penultimate, firstOne);
console.log('bracket:', bracketMinusOne, '| the old way:', oldWay);
console.log('edges  :', pastTheEnd, wayBefore, negativeZero, fractional, notANumber);
console.log('string :', lastLetter, JSON.stringify(charAtPastEnd), atPastEnd, '| borrowed:', borrowed);

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 negative index counts back from the end', [last, penultimate, firstOne], [103, 91, 12]);
test('brackets cannot: [-1] is a property name, not a position', [bracketMinusOne === undefined, oldWay], [true, 103]);
test('an index outside the array is undefined in either direction', [pastTheEnd === undefined, wayBefore === undefined], [true, true]);
test('minus zero is zero, and a fractional index is truncated towards zero', [negativeZero, fractional], [12, 45]);
test('the index is coerced, so a numeric string works', notANumber, 78);
test('on a string at gives one character back, counted from the end', lastLetter, 'a');
test('charAt answers a missing index with an empty string; at says undefined', [charAtPastEnd, atPastEnd === undefined], ['', true]);
test('at indexes UTF-16 units, so it can hand you half a code point', pieceSizes, [1, 2]);
test('and it is generic, so an array-like can borrow it', borrowed, 'Kano');

at exists for one reason: list[-1] silently does the wrong thing. Square brackets take a property key, so -1 becomes the string "-1", which no array has, and you get undefined with no complaint. Before at, the last element meant list[list.length - 1], or list.slice(-1)[0], which allocates an array to throw it away.

It is a plain index read, not a smarter one. Fractional indices are truncated, string indices are coerced to numbers, and anything landing outside the bounds is undefined — including at(-9) on a five-element array, which does not wrap around. On a string it still counts UTF-16 units, so the last "character" of a flag or an emoji is half a surrogate pair; go through Array.from or spread first if code points are what you mean.

Like most array methods it is generic. Array.prototype.at.call(arrayLike, -1) works on anything with a length, and String.prototype.at is the same idea for text. Nothing here mutates, so it is safe to reach for anywhere.

75 Object.groupBy, and the bag with no prototype

fair

Group a day of sales by area with Object.groupBy, then group the same rows into buckets computed from the price. Show three things the built-in gets right that a hand-rolled version does not: the object it returns has no prototype, its keys are strings whatever you return, and Map.groupBy keeps the key exactly as it was.

Stuck? Three hints, smallest first
  1. 1. The callback is (item, index) and whatever it returns becomes a key, coerced to a string.
  2. 2. The result comes from Object.create(null), so it has no toString, no constructor and no hasOwnProperty.
  3. 3. That matters because a group named "toString" would collide with an inherited method on a plain {}.

The answer

const sales = [
  { good: 'garri', area: 'Ikeja', kobo: 45000 },
  { good: 'rice', area: 'Yaba', kobo: 450000 },
  { good: 'beans', area: 'Ikeja', kobo: 130000 },
  { good: 'palm oil', area: 'Surulere', kobo: 120000 },
];

const byArea = Object.groupBy(sales, ({ area }) => area);
const areas = Object.keys(byArea);
const ikejaGoods = byArea.Ikeja.map(({ good }) => good);

const byBucket = Object.groupBy(sales, ({ kobo }) => (kobo >= 130000 ? 'wholesale' : 'retail'));
const bucketNames = Object.entries(byBucket).map(([name, rows]) => `${name}:${rows.length}`);

const noPrototype = Object.getPrototypeOf(byArea) === null;
const inheritsNothing = ['toString' in byArea, typeof byArea.hasOwnProperty];

const awkward = Object.groupBy(['a', 'b'], () => 'toString');
const naiveGroupBy = (list, fn) => {
  const out = {};
  for (const item of list) (out[fn(item)] ??= []).push(item);
  return out;
};
let naiveResult;
try {
  naiveResult = naiveGroupBy(['a', 'b'], () => 'toString');
} catch (error) {
  naiveResult = error.constructor.name;
}

const coerced = Object.keys(Object.groupBy([1, 2, 3], (n) => n % 2 === 0));
const missing = Object.keys(Object.groupBy([1], () => undefined));
const keptAsKeys = [...Map.groupBy([1, 2, 3], (n) => n % 2 === 0).keys()];

console.log('by area  :', areas, ikejaGoods);
console.log('buckets  :', bucketNames);
console.log('prototype:', noPrototype, inheritsNothing);
console.log('naive    :', naiveResult, '| built-in:', Object.entries(awkward).map(([k, v]) => [k, v.length]));

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('groups come out keyed by whatever the callback returned', areas, ['Ikeja', 'Yaba', 'Surulere']);
test('and each group keeps its rows in input order', ikejaGoods, ['garri', 'beans']);
test('the key can be computed, which is how you bucket a range', bucketNames, ['retail:2', 'wholesale:2']);
test('the result has no prototype at all', noPrototype, true);
test('so it inherits nothing you could collide with', inheritsNothing, [false, 'undefined']);
test('a group called toString is therefore fine', Object.entries(awkward).map(([k, v]) => [k, v.length]), [['toString', 2]]);
test('the same grouping over a plain object literal throws instead', naiveResult, 'TypeError');
test('an object key is always a string, so booleans and undefined are stringified', [coerced, missing], [[['false'], ['true']].flat(), ['undefined']]);
test('Map.groupBy keeps the key it was given, type and all', keptAsKeys, [false, true]);

Object.groupBy is the one-line version of the accumulator everyone writes by hand, and the interesting part is what it returns: an object with a null prototype. That is not a detail. Group by a user-supplied string and a plain {} accumulator hands you Object.prototype.toString the moment someone's data says "toString", so (out[key] ??= []).push(...) tries to push onto a function and throws. With no prototype there is nothing to inherit and nothing to shadow.

The price of an object is that keys are strings. Group by a boolean and you get "true" and "false"; group by a date, an object or undefined and you get whatever String() makes of it, which for objects is uselessly "[object Object]". Map.groupBy is the same function with real keys: booleans stay booleans, and object keys compare by identity.

Two smaller things. Each group preserves the input order, so grouping is stable and you can sort once beforehand instead of sorting every group. And because the result has no hasOwnProperty, reach for Object.hasOwn(groups, key) or plain key in groups rather than the method you are used to calling on it.

76 A tagged template for a receipt, and String.raw

fair

Write a tag function that formats every interpolated number as naira and leaves the text between untouched. Prove the shape of what a tag receives, then use String.raw to keep a backslash as a backslash, and look at what raw holds when the escape sequence was not even valid.

Stuck? Three hints, smallest first
  1. 1. A tag is called as tag(strings, ...values), and strings.length is always values.length + 1.
  2. 2. strings.raw is the same list before escape sequences were processed.
  3. 3. In a tagged template an invalid escape is allowed: the cooked string becomes undefined and only raw survives.

The answer

const money = (strings, ...values) =>
  strings.reduce((out, part, i) => {
    const value = values[i];
    const shown =
      i >= values.length ? '' : typeof value === 'number' ? `₦${(value / 100).toFixed(2)}` : String(value);
    return out + part + shown;
  }, '');

const buyer = 'Adaeze';
const kobo = 45000;
const qty = 3;
const bags = `${qty} bags`;
const receipt = money`${buyer} takes ${bags} and pays ${kobo * qty}`;
const noHoles = money`nothing to fill`;

const shape = (strings, ...values) => ({
  parts: [...strings],
  values,
  counts: [strings.length, values.length],
  frozen: Object.isFrozen(strings),
});
const inspected = shape`a${1}b${2}c`;
const edges = shape`${'first'}middle${'last'}`;

const rawText = String.raw`line\nbreak`;
const cookedText = `line\nbreak`;
const fifthChar = [rawText.charCodeAt(4), cookedText.charCodeAt(4)];

const peek = (strings) => [strings[0], strings.raw[0]];
const invalid = peek`\unit`;

const pattern = new RegExp(String.raw`\d{4}-\d{2}`);
const matched = pattern.test('2019-05');

console.log('receipt:', receipt);
console.log('shape  :', inspected);
console.log('edges  :', edges.parts, edges.values);
console.log('raw    :', rawText, '| lengths:', rawText.length, cookedText.length);

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 tag decides how each interpolated value is rendered, by type', receipt, 'Adaeze takes 3 bags and pays ₦1350.00');
test('a template with no holes still arrives as one string and no values', [noHoles, shape`x`.counts], ['nothing to fill', [1, 0]]);
test('there is always one more string than there are values', [inspected.parts, inspected.values, inspected.counts], [['a', 'b', 'c'], [1, 2], [3, 2]]);
test('so a hole at either end shows up as an empty string', [edges.parts, edges.values], [['', 'middle', ''], ['first', 'last']]);
test('the strings array is frozen, so a tag cannot rewrite its own input', inspected.frozen, true);
test('String.raw keeps the backslash; a normal template turns it into a newline', fifthChar, [92, 10]);
test('which is why it is the honest way to write a regular expression', [matched, pattern.source.length], [true, 11]);
test('an invalid escape has no cooked form at all, only a raw one', [invalid[0] === undefined, invalid[1].charCodeAt(0), invalid[1].length], [true, 92, 5]);

A tagged template hands the tag the literal text and the interpolated values separately, and that separation is the whole point. The tag can format values, escape them, count them or ignore them, and it always knows which parts of the string the author wrote and which came from data. Every safe-HTML and safe-SQL template helper is built on exactly that distinction.

The shape is fixed: one more string than values, empty strings at the ends when a hole starts or finishes the template, and the array is frozen so a tag cannot mutate what it was handed. strings.raw carries the same text before escapes were processed, which is what String.raw returns wholesale — the reason a Windows path or a regular expression source is far more readable through it than through a wall of doubled backslashes.

The last assertion is the odd corner. Inside a tagged template an invalid escape sequence is no longer a syntax error: the cooked entry becomes undefined and the raw entry keeps the characters you typed. So a tag that reads strings[i] can receive undefined from perfectly deliberate input, and a tag meant for path-like or regex-like text should read strings.raw instead.

Newsletter

New writing, when it lands.

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