Emmanuel Akinfulubi

Lesson 13 of 1511 min read

Abstraction

The driver does not think about clutch plates and synchroniser rings. He thinks "change to third gear". Abstraction is the deliberate act of deciding what the person using your object never has to know.

By the end: Design an interface that hides the machinery behind it.

A driver thinks "change to third gear". He does not think about which plate the clutch presses, how the gears line up inside the box, or the fluid pressure in the pipes. All of that exists, and all of it is somebody else's problem.

Abstraction is the act of deciding, on purpose, what the person using your object never needs to know. Encapsulation is the tool that does it: the # and the getters. Abstraction is the choice of what to point them at.

A leaky interface, and a sealed one

Both classes below do the same job. Read the two call sites and notice how much the caller has to understand.

Leaky: the caller must know the procedure

class LeakyBooking {
  constructor() {
    this.seats = [];
    this.locked = false;
    this.total = 0;
  }
}

const b = new LeakyBooking();

// The caller has to know the order, the fee, and the flag. Every caller does.
b.seats.push('1A');
b.seats.push('1B');
b.total = b.seats.length * 12000;
b.total += 200;                 // booking fee, remembered by hand
b.locked = true;

console.log(b);
console.log('Did every caller remember the fee? There is no way to tell.');

Output


      

Sealed: the caller states intent, once

class Booking {
  static SEAT_NAIRA = 12000;
  static FEE_NAIRA = 200;

  #seats = new Set();
  #confirmed = false;

  add(seat) {
    if (this.#confirmed) throw new Error('This booking is already confirmed.');
    if (this.#seats.has(seat)) throw new RangeError(`Seat ${seat} is already on this booking.`);
    this.#seats.add(seat);
    return this;
  }

  get seats() { return [...this.#seats]; }

  get totalNaira() {
    if (this.#seats.size === 0) return 0;
    return this.#seats.size * Booking.SEAT_NAIRA + Booking.FEE_NAIRA;
  }

  get total() {
    return `₦${this.totalNaira}`;
  }

  confirm() {
    if (this.#seats.size === 0) throw new Error('Cannot confirm an empty booking.');
    this.#confirmed = true;
    return this;
  }

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

const booking = new Booking().add('1A').add('1B').confirm();

console.log(booking.seats, booking.total, 'confirmed:', booking.confirmed);

try { booking.add('1C'); } catch (err) { console.log('refused:', err.message); }
try { new Booking().confirm(); } catch (err) { console.log('refused:', err.message); }

Output


      

Abstract base classes

JavaScript has no abstract keyword. You get the same effect by making the base class refuse to be instantiated and refuse to answer the methods a subclass must supply.

A base class that will not be used directly

class Vehicle {
  constructor(plate, seats) {
    if (new.target === Vehicle) {
      throw new TypeError('Vehicle is abstract. Use LuxuryBus, Coaster or Sienna.');
    }
    this.plate = plate;
    this.seats = seats;
  }

  // Subclasses must supply this.
  load() {
    throw new Error(`${this.constructor.name} must implement load().`);
  }

  // Shared behaviour, written once.
  describe() {
    return `${this.plate}, ${this.seats} seats (${this.constructor.name})`;
  }
}

class LuxuryBus extends Vehicle {
  constructor(plate, seats, tier) { super(plate, seats); this.tier = tier; }
  load() { return `boarding by seat number, ${this.tier} class`; }
}

class Coaster extends Vehicle {
  constructor(plate, seats, rows) { super(plate, seats); this.rows = rows; }
  load() { return `boarding from the back, ${this.rows} rows`; }
}

class Sienna extends Vehicle {}       // forgot to implement load

const yard = [
  new LuxuryBus('LAG-441-XA', 49, 'executive'),
  new Coaster('ABJ-772-KM', 30, 8),
  new Sienna('ENU-318-PH', 7),
];

for (const v of yard) {
  try {
    console.log(v.describe(), '->', v.load());
  } catch (err) {
    console.log(v.describe(), '-> INCOMPLETE:', err.message);
  }
}

try { new Vehicle('x', 0); } catch (err) { console.log('refused:', err.message); }

Output


      

Enforcing the contract at construction time

The version above fails when a missing method is finally called, which could be in production at 8pm. You can fail at construction instead, which is much earlier.

Refuse to build an incomplete subclass

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

  constructor(plate) {
    if (new.target === Vehicle) throw new TypeError('Vehicle is abstract.');

    const missing = Vehicle.REQUIRED.filter(name => typeof this[name] !== 'function');
    if (missing.length) {
      throw new TypeError(
        `${new.target.name} is missing: ${missing.join(', ')}. ` +
        `Every Vehicle must implement ${Vehicle.REQUIRED.join(' and ')}.`
      );
    }

    this.plate = plate;
  }
}

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

class Sienna extends Vehicle {
  load() { return 'loaded'; }
  // no checklist
}

console.log(new Coaster('ABJ-772-KM').plate, '->', new Coaster('ABJ-772-KM').load());

try {
  new Sienna('ENU-318-PH');
} catch (err) {
  console.log('refused at construction:', err.message);
}

Output


      

Hiding a dependency behind a small surface

The most valuable abstraction in practice is one that hides how something is stored or fetched. Write to the small surface and you can swap the machinery underneath without touching a caller.

Two very different stores, one interface

// The interface: save(key, value), load(key), keys(). That is all a caller sees.

class MemoryStore {
  #data = new Map();
  save(key, value) { this.#data.set(key, structuredClone(value)); return this; }
  load(key) { const v = this.#data.get(key); return v === undefined ? null : structuredClone(v); }
  keys() { return [...this.#data.keys()]; }
}

class PrefixedStore {
  // Same three methods. Stores everything under a namespace and logs writes.
  #inner;
  #prefix;
  #writes = 0;
  constructor(inner, prefix) { this.#inner = inner; this.#prefix = prefix; }
  save(key, value) { this.#writes++; this.#inner.save(this.#prefix + key, value); return this; }
  load(key) { return this.#inner.load(this.#prefix + key); }
  keys() { return this.#inner.keys().filter(k => k.startsWith(this.#prefix)); }
  get writes() { return this.#writes; }
}

// A caller written against the interface, not against either class.
function seed(store) {
  store.save('booking:1', { seats: ['1A', '1B'] });
  store.save('booking:2', { seats: ['3C'] });
  return store.keys();
}

const plain = new MemoryStore();
console.log('plain:', seed(plain));

const wrapped = new PrefixedStore(new MemoryStore(), 'park.');
console.log('wrapped:', seed(wrapped), '| writes counted:', wrapped.writes);

// And the caller still reads the same way.
console.log(wrapped.load('booking:1'));

Output


      

Choosing what to expose

Practical rules that hold up over time.

Reaching through, and the fix

class PostalCode { constructor(text) { this.text = text; } }
class Address { constructor(code) { this.postalCode = new PostalCode(code); } }
class Customer { constructor(name, code) { this.name = name; this.address = new Address(code); } }

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

  // Bad: forces callers to know about Customer, Address and PostalCode.
  get customer() { return this.#customer; }

  // Good: one property, and the chain underneath is free to change.
  get postalCode() { return this.#customer.address.postalCode.text; }
  get customerName() { return this.#customer.name; }
}

const booking = new Booking(new Customer('Amaka Obi', '100001'));

console.log('reaching through:', booking.customer.address.postalCode.text);
console.log('through the surface:', booking.postalCode, '|', booking.customerName);

Output


      

Your turn: seal the interface

The timer below exposes everything. Rewrite it so a caller can only start(), stop(), and read elapsed and running. Calling start() twice should be harmless, and stop() before start() should throw a clear message. All internal fields private.

Editable. Seal it, then run it.

Output


      

Recap

  • Abstraction is deciding what the caller never needs to know; encapsulation is the tool that makes the decision stick.
  • Judge an interface by how much a caller must understand to use it correctly.
  • There is no abstract keyword: use new.target to refuse being built directly, and throw from methods a subclass must supply.
  • Checking for required methods in the base constructor moves the failure from runtime to construction time.
  • Expose intentions rather than steps, and never make callers reach through your object into another one.

Saved in this browser only.

Newsletter

New writing, when it lands.

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