JAVASCRIPT / PROTOTYPES, CLASSES, AND INHERITANCE
Composition against deep inheritance chains
Rebuild a deep extends chain as objects that hold small capability collaborators, mix them per instance, and swap them at runtime.
What you will learn
- Recognise when two independent axes of variation make subclass counts multiply
- Merge capability functions over one shared state object instead of adding extends levels
- Inject a strategy object and replace it on a live instance, which a subclass cannot do
- Wrap a collaborator and publish a narrow API instead of inheriting a whole parent
Understanding Composition against deep inheritance chains
Every `extends` fuses two separate decisions: that instances are substitutable for the parent type, and that they reuse the parent's code. Three or four levels deep, one method call can be answered by any of four prototypes, so reading a leaf class no longer tells you what it does, and editing a middle class silently changes every descendant that was written against the old behaviour. The harder limit is that a class has exactly one prototype parent, so as soon as objects vary along a second independent axis, the chain has to enumerate every combination of the two.
Composition changes the question from what is this object to what can it do and who does it for it. An object becomes a small state record plus a few collaborators it forwards to, chosen when the object is built rather than when its class was written. Because those collaborators are ordinary values, two independent axes cost two small objects instead of n times m classes, and a capability can be replaced on an object that already exists, which subclassing can never do since an instance's class is fixed at construction.
In JavaScript this shows up in three shapes: a factory that builds one state object and merges capability functions over it with spread or Object.assign; a class that takes a strategy object in its constructor and calls through it; and a mixin object copied onto a prototype. The price is one more level of indirection, forwarding methods you write by hand, and the loss of `instanceof` as a way to ask what an object can do. Inheritance still earns its keep one level deep over a base you control, when callers genuinely must treat the subtype as the base type; depth added purely for code reuse is the warning sign.
placeholder
// Three capabilities, each a function over one shared state object.
const movable = (s) => ({
move(dx, dy) {
s.x += dx;
s.y += dy;
return `${s.name} -> (${s.x}, ${s.y})`;
}
});
const damageable = (s) => ({
damage(n) { s.hp = Math.max(0, s.hp - n); },
status() { return `${s.name}: ${s.hp} hp`; }
});
const armed = (s) => ({
attack(target) {
target.damage(s.power);
return `${s.name} hits for ${s.power}`;
}
});
// A knight moves, takes hits, and hits back. A wall only takes hits.
function knight(name) {
const s = { name, x: 0, y: 0, hp: 30, power: 7 };
return { ...movable(s), ...damageable(s), ...armed(s) };
}
function wall(name) {
const s = { name, hp: 50 };
return { ...damageable(s) };
}
const arthur = knight('Arthur');
const gate = wall('Gate');
console.log(arthur.move(2, 3));
console.log(arthur.attack(gate));
console.log(gate.status());
console.log('gate.move is', typeof gate.move);
console.log('one level deep?', Object.getPrototypeOf(arthur) === Object.prototype);Depth in a hierarchy buys reuse by permanently deciding what an object is, while composition keeps each behaviour a replaceable value the object holds, so capabilities add instead of multiply.
Worked examples
Swapping a strategy on a live object
A pay policy held as a collaborator can be replaced after construction, where a subclass choice cannot.
const hourly = (rate) => ({
describe: () => `hourly at ${rate}/h`,
amountFor: (hours) => rate * hours
});
const salaried = (annual) => ({
describe: () => `salaried at ${annual}/yr`,
amountFor: () => annual / 26
});
class Employee {
constructor(name, policy) {
this.name = name;
this.policy = policy; // has-a pay policy, not is-a HourlyEmployee
}
payslip(hours) {
return `${this.name} (${this.policy.describe()}): ${this.policy.amountFor(hours).toFixed(2)}`;
}
}
const sam = new Employee('Sam', hourly(20));
console.log(sam.payslip(38));
sam.policy = salaried(52000); // promotion: same object, new behaviour
console.log(sam.payslip(38));Example explained
Line 1Both factories return objects with the same two method names, and that shared shape is the only thing `payslip` depends on.
Line 2`amountFor: () => annual / 26` ignores the hours it is handed, so each policy decides which inputs matter and the caller never branches on type.
Line 3`sam.policy = salaried(52000)` changes behaviour on an object that already exists; with `class HourlyEmployee extends Employee` the only way to change pay rules is to construct a different instance.
Holding a collaborator instead of extending it
Subclassing Array publishes the whole array API and lets callers break the stack, while wrapping an array keeps the invariant in one place.
class BadStack extends Array { // is-a array: the entire Array API comes along
peek() { return this[this.length - 1]; }
}
const b = new BadStack();
b.push('a', 'b');
b.length = 0; // emptied without going through pop
console.log('BadStack:', b.peek(), b.length);
class Stack { // has-a array: three doors in, no more
constructor() { this.items = []; }
push(v) { this.items.push(v); return this; }
peek() { return this.items[this.items.length - 1]; }
size() { return this.items.length; }
}
const s = new Stack();
s.push('a').push('b');
console.log('Stack:', s.peek(), s.size());
console.log('s.sort is', typeof s.sort);Example explained
Line 1`extends Array` inherits every array operation, so `sort`, `splice`, and assignment to `length` can all violate the stack's rules from outside.
Line 2`b.peek()` reads `this[-1]` after the length reset, which is why it prints undefined even though two values were pushed.
Line 3`this.items = []` turns the array into a collaborator, so only push, peek, and size are reachable and one method owns each change.
Line 4`push` returns `this`, showing that chainability is a property of the API you write, not something you have to inherit.
Important notes
Factory composition gives every instance its own copies of the merged methods, while prototype methods are shared; the difference only matters at very large instance counts.
Interchangeable capabilities are a contract JavaScript will not check for you, so if one policy names its method `amountFor` and another names it `calc`, the mismatch surfaces only when that policy is used.
Common mistakes
Handing each capability a fresh copy of the state, as in `movable({ ...s })`, so every capability mutates its own object: `move` reports new coordinates while `status` keeps printing the starting values, with no error anywhere.
Applying a mixin with `Object.assign(Child.prototype, mixin)` after the class body: a same-named method is overwritten silently, so the class's own version simply stops running and the bug only appears when that path executes.
Leaving `instanceof` checks in place after composing: objects built by a factory inherit only from Object.prototype, so a check like `x instanceof Movable` is false even for objects that clearly move, and the guard rejects everything.
Try it yourself
Change, predict, then run
In a browser console write two capability functions `canFly(state)` and `canSwim(state)`, plus a factory `createBird(name, ...caps)` that builds one state object and merges every capability onto it. Use it to make a duck with both, a penguin with swimming only, and confirm that `typeof penguin.fly` is `undefined` while `duck.fly()` works.
Open the JavaScript workspaceCheck your understanding
A Vehicle class already has ElectricCar and GasCar subclasses, and now vehicles must also vary by whether they are self-driving. Why does reaching for a SelfDrivingElectricCar class signal that inheritance is the wrong tool here?
- Prototype lookups get slower with each extra level, so a four-class chain is mainly a performance problem.
- A subclass cannot override a method that is defined two levels up the chain, so that behaviour becomes unreachable.
- Drive type and autonomy vary independently, so a single chain must enumerate every pairing while injected collaborators can be mixed freely.
- Instances built from a deep chain lose the fields set by the base constructor unless every level repeats them.
Show answer
Two independent axes multiply: two drive types times two autonomy modes is four classes, and adding a hybrid or a remote-control mode multiplies again, whereas holding a `drive` object and an `autopilot` object keeps the count additive. The performance option is tempting because a longer chain really does add lookup steps, but engines cache property lookups and composition adds a forwarding call of its own; the real cost of the deep chain is the class count and the coupling, not speed.