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.
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
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.
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.
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.
// 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.
- Expose intentions, not steps.
booking.confirm(), notbooking.locked = true. - Do not let a caller reach a second object through yours.
booking.customer.address.postalCodeties every caller to three classes. Addbooking.postalCodeinstead. - Make illegal states impossible rather than documented. If a booking cannot be both empty and confirmed, do not provide a way to express that.
- Fewer methods, each doing something whole. A class with twenty methods usually contains two classes.
- Name for the caller's world, not yours.
remainingbeatscapacityMinusSoldSize.
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.
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
abstractkeyword: usenew.targetto 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.