Emmanuel Akinfulubi

Drills

29 exercises, grouped by the lesson that prepares you for them. Each one has an editable panel and a worked solution you can reveal. Type your attempt first: reading a solution feels like learning and mostly is not.

After lesson 01: What an Object Actually Is

01

Describe a bicycle

easy

Make an object with make, gears, an array of accessories, and a nested frame object holding material. Log the make, the number of accessories, and the frame material.

Your attempt

Output


      
Trip a worked solution

One way to do it

const bike = {
  make: 'Brompton',
  gears: 6,
  accessories: ['bell', 'front bag', 'lights'],
  frame: { material: 'steel' },
};

console.log(bike.make);
console.log(bike.accessories.length, 'accessories');
console.log(bike.frame.material);

Output


      

Nothing here needs a class. When a shape is described once and never built repeatedly, a literal is the right answer.

02

Copy without sharing

easy

Make copy a genuinely independent copy of original so that changing copy.frame.material leaves the original alone. Then explain in a comment why the spread operator is not enough.

Your attempt

Output


      
Trip a worked solution

One way to do it

const original = { make: 'Brompton', frame: { material: 'steel' } };

// Spread copies one level, so frame would still be shared:
const shallow = { ...original };
shallow.frame.material = 'aluminium';
console.log('after shallow edit, original is:', original.frame.material);

// structuredClone copies all the way down.
const original2 = { make: 'Brompton', frame: { material: 'steel' } };
const deep = structuredClone(original2);
deep.frame.material = 'titanium';
console.log('after deep edit, original is:', original2.frame.material);

Output


      

Spread copies the top level only. Any nested object is still the same object, which is the source of a whole family of bugs.

After lesson 02: Properties and Methods

03

Chainable tally

easy

Give this object tick() and reset() methods that both return this, plus a report() that logs the label and count. Make tally.tick().tick().reset().tick().report() log 1.

Your attempt

Output


      
Trip a worked solution

One way to do it

const tally = {
  label: 'seats booked',
  count: 0,
  tick() { this.count++; return this; },
  reset() { this.count = 0; return this; },
  report() { console.log(this.label + ':', this.count); return this; },
};

tally.tick().tick().reset().tick().report();

Output


      

Returning this costs nothing and makes a sequence of operations read as one sentence.

04

Loop an object properly

easy

Log every key and value of record as "key = value", but skip the method. Use Object.entries and a check on the value type.

Your attempt

Output


      
Trip a worked solution

One way to do it

const record = {
  plate: 'LAG-441-XA',
  seatsLeft: 9,
  ready: true,
  describe() { return this.plate; },
};

for (const [key, value] of Object.entries(record)) {
  if (typeof value === 'function') continue;
  console.log(key, '=', value);
}

Output


      

A method is just a property, so it turns up in Object.entries. Classes avoid this by keeping methods on the prototype.

After lesson 03: The Word this

05

Predict this

fair

Write down what each of the five logs prints before you run it. Then fix line 4 so it prints the room name, without changing the report method.

Your attempt

Output


      
Trip a worked solution

One way to do it

const room = {
  name: 'Jibowu park',
  report() { return this?.name ?? 'no owner'; },
};

const loose = room.report;
const other = { name: 'Utako park', report: room.report };

console.log('1:', room.report());                  // Jibowu park
console.log('2:', other.report());                 // Utako park
console.log('3:', loose());                        // no owner
console.log('4:', [1].map(() => room.report())[0]); // wrap the call
console.log('4b:', [1].map(loose.bind(room))[0]);  // or bind it
console.log('5:', room.report.call({ name: 'park' }));  // park

Output


      

The method never changed. Only the call site did, which is the entire lesson about this.

06

Arrow in the wrong place

fair

This object has one method written as an arrow and one callback written as a regular function. Both are wrong. Fix both, and say in a comment which fix goes where.

Your attempt

Output


      
Trip a worked solution

One way to do it

const park = {
  name: 'Okafor',
  buses: ['LAG-441-XA', 'ABJ-772-KM'],

  // A method needs its own `this`, so it must be a regular function.
  label() { return 'made at ' + this.name; },

  // A callback wants to borrow the method's `this`, so it must be an arrow.
  listing() {
    return this.buses.map(p => p + ' @ ' + this.name);
  },
};

console.log(park.label());
console.log(park.listing());

Output


      

The two cases are exact opposites, and the same property of arrow functions explains both.

After lesson 04: Factory Functions

07

Private balance

fair

Write makeAccount(owner, opening) using a closure so the balance cannot be read or written directly. Expose deposit, withdraw (which refuses to overdraw), and balance(). Then prove from outside that the balance is unreachable.

Your attempt

Output


      
Trip a worked solution

One way to do it

function makeAccount(owner, opening) {
  if (opening < 0) throw new RangeError('Opening balance cannot be negative.');
  let balance = opening;

  return {
    owner,
    balance() { return balance; },
    deposit(n) {
      if (n <= 0) throw new RangeError('Deposit must be positive.');
      balance += n;
      return this;
    },
    withdraw(n) {
      if (n > balance) throw new RangeError('Balance is only ' + balance + '.');
      balance -= n;
      return this;
    },
  };
}

const acct = makeAccount('LAG-441-XA', 100);
acct.deposit(50).withdraw(30);
console.log('balance:', acct.balance());
console.log('own keys:', Object.keys(acct));
console.log('acct.balance is a function, not a number:', typeof acct.balance);
try { acct.withdraw(1000); } catch (err) { console.log('refused:', err.message); }

Output


      

There is no property to overwrite. That is stronger than any naming convention.

08

A factory that refuses

fair

Write makeSeat({ row, number, tier }). Rows are A to D only, numbers 1 to 20, tier defaults to "luxury". Throw a message naming the problem and the allowed values. Return an object with a label getter.

Your attempt

Output


      
Trip a worked solution

One way to do it

const ROWS = ['A', 'B', 'C', 'D'];

function makeSeat({ row, number, tier = 'luxury' }) {
  if (!ROWS.includes(row)) {
    throw new RangeError(`Row "${row}" does not exist. Rows are ${ROWS.join(', ')}.`);
  }
  if (!Number.isInteger(number) || number < 1 || number > 20) {
    throw new RangeError(`Seat number must be a whole number 1 to 20, got ${number}.`);
  }

  return {
    row,
    number,
    tier,
    get label() { return this.row + this.number; },
  };
}

console.log(makeSeat({ row: 'D', number: 12 }).label);
console.log(makeSeat({ row: 'A', number: 1, tier: 'economy' }));
try { makeSeat({ row: 'Z', number: 1 }); } catch (e) { console.log('refused:', e.message); }
try { makeSeat({ row: 'A', number: 99 }); } catch (e) { console.log('refused:', e.message); }

Output


      

An error message that names the value it got and the values it wanted saves the next person ten minutes.

After lesson 05: Constructors and new

09

Write new by hand

hard

Implement construct(Ctor, ...args) that does what new does, without using new anywhere. It must pass all four checks below.

Your attempt

Output


      
Trip a worked solution

One way to do it

function Bus(plate, seatsLeft) {
  this.plate = plate;
  this.seatsLeft = seatsLeft;
}
Bus.prototype.describe = function () { return this.plate + '/' + this.seatsLeft; };

function construct(Ctor, ...args) {
  const obj = Object.create(Ctor.prototype);        // steps 1 and 2
  const result = Ctor.apply(obj, args);             // step 3
  return (typeof result === 'object' && result !== null) ? result : obj;  // step 4
}

const p = construct(Bus, 'LAG-441-XA', 9);
console.log('1 describe works :', p.describe());
console.log('2 instanceof     :', p instanceof Bus);
console.log('3 constructor    :', p.constructor === Bus);
console.log('4 own data only  :', Object.keys(p));

Output


      

Once you have written those four lines, class has no mystery left in it.

10

Move methods to the prototype

fair

This constructor gives every instance its own copy of two methods. Move them so all instances share one copy each, and prove it with an identity check.

Your attempt

Output


      
Trip a worked solution

One way to do it

function Stop(label, minutes) {
  this.label = label;
  this.minutes = minutes;
}

Stop.prototype.describe = function () { return this.label + ' @ ' + this.minutes + ' min'; };
Stop.prototype.delay = function (n) { this.minutes += n; return this; };

const a = new Stop('Ore', 90);
const b = new Stop('Lokoja', 240);

console.log(a.delay(3).describe());
console.log('shared?', a.describe === b.describe);
console.log('own keys:', Object.keys(a));

Output


      

Data on the instance, behaviour on the prototype. This is exactly what a class body does for you.

After lesson 06: Prototypes and the Chain Above

11

Trace the lookup

fair

Predict all five logs, then run it. For each, say whether the value was found on the object, on the prototype, or not at all.

Your attempt

Output


      
Trip a worked solution

One way to do it

// 1: "LAG-441-XA (sienna)" — plate and kind both own, describe from the prototype
// 2: ["plate", "kind"] — only own properties
// 3: false / true — not own, but reachable up the chain
// 4: "LAG-441-XA (coaster)" — deleting the own copy reveals the inherited one
// 5: undefined — not found anywhere, and that is not an error

function Ride(plate) { this.plate = plate; }
Ride.prototype.kind = 'coaster';
Ride.prototype.describe = function () { return this.plate + ' (' + this.kind + ')'; };

const r = new Ride('LAG-441-XA');
r.kind = 'sienna';
console.log(r.describe());
delete r.kind;
console.log(r.describe());

Output


      

Assignment always writes to the object itself, which is why deleting an own property can make a value reappear.

12

The shared array trap

hard

Two buses are sharing one array of faults. Find why, fix it, and then explain in a comment why moving the array into the constructor works but reassigning it in a method would not have.

Your attempt

Output


      
Trip a worked solution

One way to do it

function Bus(plate) {
  this.plate = plate;
  this.faults = [];        // a fresh array per instance
}
Bus.prototype.report = function (what) { this.faults.push(what); return this; };

const a = new Bus('LAG-441-XA');
const b = new Bus('ABJ-772-KM');
a.report('cracked windscreen');

console.log('a:', a.faults);
console.log('b:', b.faults);
console.log('same array?', a.faults === b.faults);

// push() mutates the one shared array, so it was visible from every instance.
// Assigning this.faults = [...] inside a method would have created an own
// property, but only for whichever instance happened to be assigned to first.

Output


      

Mutation reaches through the prototype chain. Assignment does not. That asymmetry is the whole bug.

After lesson 07: class: the Same Thing, Less Typing

13

Factory to class

easy

Convert this factory to a class called Ticket with identical behaviour, then check both produce the same output.

Your attempt

Output


      
Trip a worked solution

One way to do it

class Ticket {
  reserved = false;

  constructor(row, seat) {
    this.row = row;
    this.seat = seat;
  }

  get label() { return this.row + this.seat; }

  reserve() { this.reserved = true; return this; }
}

function makeTicket(row, seat) {
  return { row, seat, reserved: false, get label() { return this.row + this.seat; }, reserve() { this.reserved = true; return this; } };
}

const fromClass = new Ticket('D', 12);
const fromFactory = makeTicket('D', 12);

console.log(fromClass.label, fromClass.reserve().reserved);
console.log(fromFactory.label, fromFactory.reserve().reserved);
console.log('class keeps methods off the instance:', Object.keys(fromClass), 'vs', Object.keys(fromFactory));

Output


      

Note the difference in Object.keys. The class instance owns only data.

14

Nine members

fair

Write one class using all nine kinds of member: public field, private field, static field, constructor, method, getter, setter, static method, and an arrow field. Keep it small and log each one working.

Your attempt

Output


      
Trip a worked solution

One way to do it

class Kit {
  parts = 0;                                   // public field
  #serial = 'K-001';                           // private field
  static made = 0;                             // static field

  constructor(name) {                          // constructor
    this.name = name;
    Kit.made++;
  }

  add(n) { this.parts += n; return this; }      // method

  get summary() { return `${this.name}: ${this.parts} parts`; }   // getter

  set summary(text) {                          // setter
    const [name, parts] = text.split(':');
    this.name = name.trim();
    this.parts = Number(parts);
  }

  static blank() { return new Kit('blank'); }   // static method

  stamp = () => `${this.name} #${this.#serial}`;  // arrow field, bound forever
}

const k = new Kit('luxury bus kit').add(12);
console.log(k.summary);
k.summary = 'rod kit: 5';
console.log(k.summary);
console.log(Kit.blank().summary, '| made:', Kit.made);
const loose = k.stamp;
console.log('arrow survives detachment:', loose());

Output


      

Having written each one once, you will recognise all of them in other people’s code.

After lesson 08: Encapsulation

15

Seal the leak

fair

Make seatsLeft and hasBookings private, expose seatsLeft read-only, and make book the only path that changes it. Then show that forcing the value from outside is impossible.

Your attempt

Output


      
Trip a worked solution

One way to do it

class Bus {
  #seatsLeft;
  #hasBookings = false;

  constructor(plate, seatsLeft) {
    this.plate = plate;
    this.#seatsLeft = seatsLeft;
  }

  get seatsLeft() { return this.#seatsLeft; }
  get hasBookings() { return this.#hasBookings; }

  book(n) {
    if (!Number.isInteger(n) || n < 1) throw new RangeError('Book a whole positive number.');
    if (n > this.#seatsLeft) throw new RangeError(`Only ${this.#seatsLeft} seats left.`);
    this.#seatsLeft -= n;
    this.#hasBookings = true;
    return this;
  }
}

const p = new Bus('LAG-441-XA', 9);
p.book(3);
console.log('seatsLeft:', p.seatsLeft, '| hasBookings:', p.hasBookings);

// There is no setter, so in strict mode the assignment throws outright.
try { p.seatsLeft = 1000; } catch (err) { console.log(err.name + ':', err.message); }
console.log('still:', p.seatsLeft);
console.log('nothing to see:', Object.keys(p), JSON.stringify(p));
try { p.book(99); } catch (err) { console.log('refused:', err.message); }

Output


      

A getter with no setter makes the property read-only. Add a throwing setter if you want the mistake to be loud.

16

Brand check

hard

Write a static isOne(value) that returns true only for genuine instances, and false for an object that merely has the same public shape. Do not use instanceof.

Your attempt

Output


      
Trip a worked solution

One way to do it

class Token {
  #brand = true;

  constructor(value) { this.value = value; }

  static isOne(value) {
    // A private field can only be present on an object this class constructed.
    return typeof value === 'object' && value !== null && #brand in value;
  }
}

const real = new Token('abc');
const fake = { value: 'abc' };
const subclassed = new (class extends Token {})('abc');

console.log('real       :', Token.isOne(real));
console.log('fake       :', Token.isOne(fake));
console.log('subclass   :', Token.isOne(subclassed));
console.log('naive check:', 'value' in fake);

Output


      

This is more reliable than instanceof across module boundaries, where two copies of the same class can exist.

After lesson 09: Getters and Setters

17

Two units, one value

fair

Store celsius privately. Provide celsius and fahrenheit as getter and setter pairs, so setting either is visible through the other. Reject anything below -273.15.

Your attempt

Output


      
Trip a worked solution

One way to do it

class Reading {
  #celsius = 0;

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

  set celsius(value) {
    const n = Number(value);
    if (Number.isNaN(n)) throw new TypeError('Temperature must be a number.');
    if (n < -273.15) throw new RangeError(`${n}C is below absolute zero.`);
    this.#celsius = n;
  }

  get fahrenheit() { return this.#celsius * 9 / 5 + 32; }

  set fahrenheit(value) { this.celsius = (Number(value) - 32) * 5 / 9; }

  toString() { return `${this.#celsius}C / ${this.fahrenheit}F`; }
}

const r = new Reading();
r.celsius = 100;
console.log(r.fahrenheit);
r.fahrenheit = 32;
console.log(r.celsius);
console.log(String(r));
try { r.celsius = -300; } catch (err) { console.log('refused:', err.message); }

Output


      

The fahrenheit setter routes through the celsius setter, so validation is written once.

18

Do not hand out the array

fair

This getter leaks the real array, so a caller can edit internal state. Fix it, and add a total getter that cannot drift out of date.

Your attempt

Output


      
Trip a worked solution

One way to do it

class Cart {
  #items = [];

  add(price) {
    if (typeof price !== 'number' || price <= 0) throw new RangeError('Price must be positive.');
    this.#items.push(price);
    return this;
  }

  get items() { return [...this.#items]; }        // a snapshot, not the original
  get count() { return this.#items.length; }
  get total() { return this.#items.reduce((n, p) => n + p, 0); }
}

const cart = new Cart().add(12).add(8);
cart.items.push(9999);                            // changes the snapshot only
console.log('items:', cart.items, '| total:', cart.total);

Output


      

A derived getter cannot go stale, because there is nothing stored to go stale.

After lesson 10: Static Members

19

Sequential ids

fair

Give every ticket a sequential id like T-001 using a private static counter. Expose id read-only, a static issued getter, and a static reset().

Your attempt

Output


      
Trip a worked solution

One way to do it

class Ticket {
  static #next = 1;
  #id;

  constructor(row, seat) {
    this.row = row;
    this.seat = seat;
    this.#id = 'T-' + String(Ticket.#next++).padStart(3, '0');
  }

  get id() { return this.#id; }
  get label() { return this.row + this.seat; }

  static get issued() { return Ticket.#next - 1; }
  static reset() { Ticket.#next = 1; return Ticket; }

  toString() { return `${this.#id} ${this.label}`; }
}

console.log(String(new Ticket('D', 11)));
console.log(String(new Ticket('D', 12)));
console.log('issued:', Ticket.issued);
Ticket.reset();
console.log('after reset:', String(new Ticket('A', 1)));

Output


      

The counter belongs to the concept of a ticket, not to any one ticket, which is exactly what static means.

20

new this()

hard

A static factory on the parent currently hard-codes the parent class, so subclasses get the wrong type back. Fix it in one word, then prove the subclass now works.

Your attempt

Output


      
Trip a worked solution

One way to do it

class Bus {
  constructor(plate) { this.plate = plate; }

  // `this` in a static method is the class the call was made on.
  static make(plate) { return new this(plate); }
}

class LuxuryBus extends Bus {
  get bar() { return 'airplane bar'; }
}

const p = Bus.make('ABJ-772-KM');
const m = LuxuryBus.make('LAG-441-XA');

console.log(p.constructor.name, p instanceof Bus);
console.log(m.constructor.name, m instanceof LuxuryBus, m instanceof Bus);
console.log('subclass features survive:', m.bar);

Output


      

This one-word change is what makes inherited static factories usable at all.

After lesson 11: Inheritance: extends and super

21

Extend without breaking

fair

Write RestStop extends Stop with a facility, a describe() that calls super.describe(), and a checklist() that adds to the parent list rather than replacing it. It must still work anywhere a Stop works.

Your attempt

Output


      
Trip a worked solution

One way to do it

class Stop {
  constructor(label, minutes) { this.label = label; this.minutes = minutes; }
  describe() { return this.label + ' at ' + this.minutes + ' min'; }
  checklist() { return ['confirm timing']; }
}

class RestStop extends Stop {
  constructor(label, minutes, facility) {
    super(label, minutes);
    this.facility = facility;
  }

  describe() { return `${super.describe()} [${this.facility}]`; }

  checklist() { return [...super.checklist(), 'check level', 'confirm output routing']; }
}

for (const c of [new Stop('Ore', 90), new RestStop('Ore', 90, 'canteen')]) {
  console.log(c.describe());
  console.log('  ', c.checklist().join(', '));
}

Output


      

Spreading super.checklist() means a change to the parent list automatically reaches the child.

22

A useful custom error

fair

Write FullyBookedError extends Error carrying the route and a waiting count. Set name correctly. Then write a caller that handles it by type and rethrows anything else.

Your attempt

Output


      
Trip a worked solution

One way to do it

class FullyBookedError extends Error {
  constructor(route, waiting) {
    super(`${route} is fully booked.`);
    this.name = 'FullyBookedError';
    this.route = route;
    this.waiting = waiting;
  }
}

function book(route, left) {
  if (typeof left !== 'number') throw new TypeError('left must be a number');
  if (left === 0) throw new FullyBookedError(route, 14);
  return 'booked ' + route;
}

for (const [route, left] of [['Lagos - Abuja', 2], ['Lagos - Kano', 0], ['Lagos - Jos', 'none']]) {
  try {
    console.log(book(route, left));
  } catch (err) {
    if (err instanceof FullyBookedError) {
      console.log(err.name + ':', err.message, '| waiting:', err.waiting);
    } else {
      console.log('unexpected, would rethrow:', err.name + ':', err.message);
    }
  }
}

Output


      

Catching by type instead of by message text is the difference between error handling and string matching.

After lesson 12: Polymorphism

23

Kill the switch

fair

Replace this switch with three classes sharing a cost() method, then compute the total in a loop that mentions no type. Keep the numbers identical so you can check.

Your attempt

Output


      
Trip a worked solution

One way to do it

class TicketLine {
  static PRICE = { luxury: 18000, economy: 12000 };
  constructor(tier, qty) { this.tier = tier; this.qty = qty; }
  cost() { return TicketLine.PRICE[this.tier] * this.qty; }
  toString() { return `${this.qty} x ${this.tier} seat`; }
}

class LuggageLine {
  constructor(qty) { this.qty = qty; }
  cost() { return 500 * this.qty; }
  toString() { return `${this.qty} x luggage`; }
}

class BookingFeeLine {
  cost() { return 200; }
  toString() { return 'booking fee'; }
}

const lines = [new TicketLine('luxury', 2), new LuggageLine(3), new BookingFeeLine()];

for (const line of lines) console.log(String(line).padEnd(22), '₦' + line.cost());
console.log('total:', '₦' + lines.reduce((n, l) => n + l.cost(), 0));

// A new line type is a new class. This loop never changes again.

Output


      

The total calculation is now closed for modification and open for new line types.

24

Make your type iterable

hard

Give Fleet a Symbol.iterator so for...of, spread and destructuring all work on it, while the internal array stays private.

Your attempt

Output


      
Trip a worked solution

One way to do it

class Fleet {
  #members = [];

  add(name) { this.#members.push(name); return this; }

  get size() { return this.#members.length; }

  *[Symbol.iterator]() {
    for (const m of this.#members) yield m;
  }

  // A second, named iterator for a different view of the same data.
  *entries() {
    for (const [i, m] of this.#members.entries()) yield [i + 1, m];
  }
}

const fleet = new Fleet().add('LAG-441-XA').add('ABJ-772-KM').add('ENU-318-PH');

for (const member of fleet) console.log('for...of:', member);
console.log('spread:', [...fleet]);

const [first, second] = fleet;
console.log('destructured:', first, second);

for (const [position, name] of fleet.entries()) console.log(position + '.', name);
console.log('and the array is still private:', Object.keys(fleet));

Output


      

One well-known symbol plugs your class into four separate language features at once.

After lesson 13: Abstraction

25

Make a class abstract

fair

Make Vehicle impossible to instantiate directly, and make it refuse at construction time any subclass that has not implemented both load and checklist.

Your attempt

Output


      
Trip a worked solution

One way to do it

class Vehicle {
  static REQUIRED = ['load', 'checklist'];

  constructor(plate) {
    if (new.target === Vehicle) {
      throw new TypeError('Vehicle is abstract; extend it instead of constructing it.');
    }
    const missing = Vehicle.REQUIRED.filter(name => typeof this[name] !== 'function');
    if (missing.length) {
      throw new TypeError(
        `${new.target.name} is missing ${missing.join(' and ')}. ` +
        `Every Vehicle must implement ${Vehicle.REQUIRED.join(' and ')}.`
      );
    }
    this.plate = plate;
  }
}

class Good extends Vehicle {
  load() { return 'loaded'; }
  checklist() { return ['check the roof rack']; }
}
class Bad extends Vehicle {
  load() { return 'loaded'; }
}

console.log('good:', new Good('LAG-441-XA').load());
try { new Bad('ABJ-772-KM'); } catch (err) { console.log('refused:', err.message); }
try { new Vehicle('ENU-318-PH'); } catch (err) { console.log('refused:', err.message); }

Output


      

Failing at construction rather than at first call moves the error from the road to development.

26

Stop reaching through

fair

A caller currently writes booking.customer.address.postalCode.text, which ties it to four classes. Give Booking a surface that hides the chain, and keep the internals private.

Your attempt

Output


      
Trip a worked solution

One way to do it

class PostalCode {
  #text;
  constructor(text) { this.#text = text.toUpperCase(); }
  toString() { return this.#text; }
}

class Address {
  #postalCode;
  constructor(pc) { this.#postalCode = new PostalCode(pc); }
  get postalCode() { return String(this.#postalCode); }
}

class Customer {
  #address;
  constructor(name, pc) { this.name = name; this.#address = new Address(pc); }
  get postalCode() { return this.#address.postalCode; }
}

class Booking {
  #customer;
  constructor(customer) { this.#customer = customer; }

  get customerName() { return this.#customer.name; }
  get postalCode() { return this.#customer.postalCode; }
}

const b = new Booking(new Customer('LAG-441-XA', 'se1 9gf'));
console.log(b.customerName, '|', b.postalCode);
console.log('the chain is unreachable:', b.customer);

Output


      

Each class exposes one level. Change any internal structure and no caller notices.

After lesson 14: Composition Over Inheritance

27

Inheritance to composition

hard

A danfo has benches rather than numbered seats, so it throws from an inherited method. Refactor to composition so nothing has to lie, and the caller checks capability instead of catching errors.

Your attempt

Output


      
Trip a worked solution

One way to do it

class SeatPlan {
  #seatsLeft;
  constructor(seatsLeft) { this.#seatsLeft = seatsLeft; }
  get seatsLeft() { return this.#seatsLeft; }
  refit(n) {
    if (!Number.isInteger(n) || n < 1) throw new RangeError('Need a whole positive number.');
    this.#seatsLeft = n;
    return this;
  }
}

class Vehicle {
  #seatPlan;

  constructor(plate, seatPlan = null) {
    this.plate = plate;
    this.#seatPlan = seatPlan;
  }

  get numbered() { return this.#seatPlan !== null; }
  get seatPlan() {
    if (!this.#seatPlan) throw new Error(`${this.plate} has no seat plan.`);
    return this.#seatPlan;
  }

  describe() {
    return this.numbered
      ? `${this.plate}, ${this.#seatPlan.seatsLeft} numbered seats`
      : `${this.plate}, bench seating`;
  }
}

const fleet = [new Vehicle('LAG-441-XA', new SeatPlan(6)), new Vehicle('KAN-905-BD')];

for (const f of fleet) {
  if (f.numbered) f.seatPlan.refit(9);
  console.log(f.describe());
}
console.log('nothing threw, and nothing pretended to have seats it lacks');

Output


      

The capability check replaces the try/catch, and no class has to throw from a method it inherited.

After lesson 15: SOLID and Four Real Patterns

28

Name the smell

hard

Three snippets, each breaking exactly one SOLID principle. Name each, then rewrite it. The answers are in the solution, but write yours first.

Your attempt

Output


      
Trip a worked solution

One way to do it

// A — Single responsibility. Data, formatting, email and disk are four reasons
// to change one file. Split them.
class Report {
  #rows;
  constructor(rows) { this.#rows = [...rows]; }
  get rows() { return [...this.#rows]; }
  get total() { return this.#rows.length; }
}
class CsvFormatter { format(report) { return report.rows.join(','); } }
class Mailer { send(to, body) { return `sending ${body.length} bytes to ${to}`; } }

const report = new Report(['D11', 'D12']);
console.log(new Mailer().send('box@park', new CsvFormatter().format(report)));

// B — Open/closed. Each new shape edits a working function. Make the shape
// answer for itself.
class Circle { constructor(r) { this.r = r; } get area() { return Math.PI * this.r ** 2; } }
class Square { constructor(s) { this.s = s; } get area() { return this.s ** 2; } }
const shapes = [new Circle(2), new Square(3)];
console.log('total area:', shapes.reduce((n, s) => n + s.area, 0).toFixed(2));

// C — Dependency inversion. The rate and the clock are welded in, so this class
// cannot be tested or reused. Inject them.
class Invoice {
  #rate; #now;
  constructor({ rate = 0.2, now = () => new Date().toISOString() } = {}) {
    this.#rate = rate;
    this.#now = now;
  }
  taxOn(naira) { return Math.round(naira * this.#rate); }
  get issuedAt() { return this.#now(); }
}
const fixed = new Invoice({ rate: 0.05, now: () => '2026-01-01T00:00:00.000Z' });
console.log(fixed.taxOn(1000), fixed.issuedAt);

Output


      

All three are recognisable by shape rather than by reasoning, which is what makes the principles useful in a code review.

29

Observer from scratch

hard

Implement an Emitter with on, off and emit, where on returns an unsubscribe function. Then use it so a booking notifies two listeners and one of them unsubscribes partway through.

Your attempt

Output


      
Trip a worked solution

One way to do it

class Emitter {
  #listeners = new Map();

  on(event, fn) {
    if (typeof fn !== 'function') throw new TypeError('Listener must be a function.');
    if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
    this.#listeners.get(event).add(fn);
    return () => this.off(event, fn);
  }

  off(event, fn) {
    this.#listeners.get(event)?.delete(fn);
    return this;
  }

  emit(event, payload) {
    for (const fn of [...(this.#listeners.get(event) ?? [])]) {
      try { fn(payload); } catch (err) { console.log('listener threw:', err.message); }
    }
    return this;
  }

  get eventNames() { return [...this.#listeners.keys()]; }
}

class Trip extends Emitter {
  #sold = new Set();
  constructor(route, capacity) { super(); this.route = route; this.capacity = capacity; }
  book(seat) {
    this.#sold.add(seat);
    this.emit('booked', { seat, remaining: this.capacity - this.#sold.size });
    if (this.#sold.size === this.capacity) this.emit('soldout', { route: this.route });
    return this;
  }
}

const trip = new Trip('Lagos - Abuja', 3);
const stopAudit = trip.on('booked', d => console.log('audit:', d.seat));
trip.on('booked', d => console.log('display:', d.remaining, 'left'));
trip.on('soldout', d => console.log('FULLY BOOKED:', d.route));
trip.on('booked', () => { throw new Error('a badly written listener'); });

trip.book('D11');
stopAudit();
trip.book('D12');
trip.book('D13');

console.log('events registered:', trip.eventNames);

Output


      

Copying the listener set before iterating, and catching listener errors, are the two details that make an emitter safe in real use.

Newsletter

New writing, when it lands.

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