Composition over Inheritance

Mental model: Inheritance says what a thing is, and you get one answer forever. Composition says what a thing has, and you can pick a different set for every object.

Level: advanced · about 14 minutes

You have a Duck that swims and flies, a Penguin that swims, a Sparrow that flies, and a Robot that walks and, for reasons the product team will explain later, swims. Model that with single inheritance and you have to choose one axis to put in the tree. Everything else gets duplicated or pushed up into a base class that grows features nobody wanted.

class Bird {
  fly() { return 'flap'; }
  swim() { return 'paddle'; }     // wrong for a sparrow
}
class Penguin extends Bird {
  fly() { throw new Error('penguins do not fly'); }   // inheriting to remove
}

const p = new Penguin();
console.log(p.swim());            // 'paddle', fine
try { p.fly(); } catch (e) { console.log(e.message); }
console.log('fly' in p);          // true - the API still lies about what it can do

The tree that cannot be drawn: which parent gets swim?

const canFly = (self) => ({ fly: () => `${self.name} flaps` });
const canSwim = (self) => ({ swim: () => `${self.name} paddles` });
const canWalk = (self) => ({ walk: () => `${self.name} steps` });

const create = (state, ...behaviours) => {
  const self = { ...state };
  for (const b of behaviours) Object.assign(self, b(self));
  return self;
};

const duck = create({ name: 'duck' }, canFly, canSwim);
const penguin = create({ name: 'penguin' }, canSwim, canWalk);

console.log(duck.fly(), penguin.swim());   // 'duck flaps' 'penguin paddles'
console.log('fly' in penguin);             // false - the API tells the truth

The same four creatures, composed. Each object gets exactly the abilities it has.

Each behaviour is a small function that takes the object under construction and returns the methods it contributes, closing over self instead of relying on this. Adding a fifth ability is a new ten-line function, not a change to a shared ancestor.

Three ways to compose

  1. Behaviour factories (closures) The version above. No this, so nothing to lose when a method is passed as a callback, and private state is a local variable.
  2. Mixins onto a prototype When you do want shared functions and one prototype, copy method bundles onto it. Cheap, and it plays fine with classes.
  3. Class-factory mixins A function that takes a base class and returns a subclass. This is the one mixin style where super still works, so it composes with real inheritance.
  class Post extends Timestamped(Taggable(Doc)) {}

  post
   |
   v
  Post.prototype
   |
   v
  (anonymous Timestamped class).prototype     <- stamp()
   |
   v
  (anonymous Taggable class).prototype        <- tag()
   |
   v
  Doc.prototype
   |
   v
  Object.prototype ──> null

  Object.assign(Post.prototype, mixin) instead puts every
  method on ONE object, so there are no extra links and
  no super to reach for.
StyleShared functions?super works?Private stateBest for
Behaviour factoryno, one closure per objectn/aclosure variablessmall objects, no this worries
Object.assign mixinyes, one copy on the prototypenonone, or # on the classbolting capabilities onto existing classes
Class-factory mixinyes, one prototype per layeryes# fields per layerlayered features that must call through
extendsyesyes# fieldsone genuine is-a relationship
class Source {
  get computed() { return 'from getter'; }
  method() { return 'm'; }
}

const naive = Object.assign({}, Source.prototype);
console.log(Object.keys(naive));              // [] - methods are non-enumerable

const faithful = Object.defineProperties({}, Object.getOwnPropertyDescriptors(Source.prototype));
console.log(typeof faithful.method, faithful.computed); // 'function' 'from getter'

Dependency injection

Composition applies to collaborators too, not just methods. A unit that reaches out to grab what it needs is hard to test and hard to reuse. A unit that is handed what it needs is trivial to test, because in a test you hand it something else.

Reaching out: untestable

class UserService {
  async add(name) {
    const user = {
      id: crypto.randomUUID(),
      name,
      createdAt: Date.now(),
    };
    await db.users.insert(user);
    return user;
  }
}
// To test: fake the clock, stub
// a global, hope db exists.

Handed in: trivial to test

const createUserService = ({ store, clock, nextId }) => ({
  add(name) {
    const user = { id: nextId(), name, createdAt: clock() };
    store.save(user);
    return user;
  },
});
// In a test: pass a Map-backed store,
// () => 1000, and a counter.

The interesting part is what the second version does not need: no module mocking, no fake timers, no globals. The dependencies are parameters, so a test is just a different call.

Duck typing

JavaScript checks capability at the moment of use, not type at compile time. If it has a then method, await treats it as a promise. If it has Symbol.iterator, for...of iterates it. You can plug into these protocols without inheriting from anything.

const range = {
  from: 1,
  to: 4,
  *[Symbol.iterator]() {
    for (let i = this.from; i <= this.to; i++) yield i;
  },
};

console.log([...range]);                    // [1, 2, 3, 4]
console.log(Math.max(...range));            // 4

const fakePromise = { then: (resolve) => resolve('duck typed') };
Promise.resolve(fakePromise).then((v) => console.log(v));  // 'duck typed'

The fragile base class problem

A base class and its subclasses share more than an interface: they share implementation details. Change how the base calls its own methods and you can break a subclass that never changed a line. This is the cost that makes inheritance expensive over years rather than sprints.

class Bag {
  constructor() { this.items = []; }
  add(item) { this.items.push(item); return this; }
  addMany(list) { for (const i of list) this.add(i); return this; }  // calls add
}

class CountingBag extends Bag {
  constructor() { super(); this.added = 0; }
  add(item) { this.added += 1; return super.add(item); }
  addMany(list) { this.added += list.length; return super.addMany(list); }
}

const b = new CountingBag().addMany(['a', 'b']);
console.log(b.items.length, b.added);   // 2 4 - counted twice
// Nothing here is a bug in either class alone. The bug is the coupling:
// CountingBag had to know whether addMany reuses add.

A refactor with no API change silently double-counts.

const countingBag = (bag) => {
  let added = 0;
  return {
    add(item) { added += 1; bag.add(item); return this; },
    addMany(list) { for (const i of list) this.add(i); return this; },
    get added() { return added; },
    get items() { return bag.items; },
  };
};

class Bag2 {
  constructor() { this.items = []; }
  add(i) { this.items.push(i); return this; }
  addMany(l) { for (const i of l) this.add(i); return this; }
}

const c = countingBag(new Bag2()).addMany(['a', 'b']);
console.log(c.items.length, c.added);   // 2 2

The same feature by delegation. It cannot double count.

const withA = (self) => ({ tag: () => 'A', shared: () => 'from A' });
const withB = (self) => ({ tag: () => 'B' });

const make = (...behaviours) => {
  const self = {};
  for (const b of behaviours) Object.assign(self, b(self));
  return self;
};
const obj = make(withA, withB);
console.log([obj.tag(), obj.shared()]);

Object.assign applies sources in order, so the later behaviour overwrites tag and the last one wins. Keys nobody else defines, like shared, survive untouched. Order matters in composition just as it does in a class chain, but here you can read the order in one line at the call site.

When inheritance is the right answer

The advice is "prefer composition", not "never inherit". Inheritance earns its keep when the relationship is genuinely is-a, the base is stable, the hierarchy is shallow, and every subclass can honour the base contract without disabling anything.

SituationReach for
Subclassing Error for a typed failureinheritance, one level
Extending a framework base class the framework requiresinheritance, as documented
A shared interface with two or three stable variants (shapes, tokens, AST nodes)inheritance, shallow
Optional capabilities in combinations (flyable, serialisable, cacheable)composition or mixins
Reusing an implementation while changing the contractdelegation, hold an instance
Anything needing a fake in tests (clock, store, network)dependency injection
Sharing code between unrelated typesa plain function
Inheritance question
is this a kind of that
Composition question
does this need that ability
Delegation question
can this just hold one of those
Injection question
would I want to replace it in a test
Depth alarm
three levels, or any subclass that disables a method
Cheapest tool of all
a function that takes arguments and returns a value

Inheritance couples you to a decision you made on day one. Composition lets you decide per object, per call.

The trade-off in one line

Try it yourself

Compose four creatures

const canFly = (self) => ({ fly: () => `${self.name} flaps` });
const canSwim = (self) => ({ swim: () => `${self.name} paddles` });
const canWalk = (self) => ({ walk: () => `${self.name} steps` });

const create = (state, ...behaviours) => {
  const self = { ...state };
  for (const b of behaviours) Object.assign(self, b(self));
  return self;
};

const duck = create({ name: 'duck' }, canFly, canSwim, canWalk);
const penguin = create({ name: 'penguin' }, canSwim, canWalk);
const robot = create({ name: 'robot' }, canWalk, canSwim);

for (const c of [duck, penguin, robot]) {
  console.log(c.name, Object.keys(c).filter((k) => typeof c[k] === 'function'));
}

Add canDive and give it to the penguin only. Then swap the behaviour order and see which method wins.

Mixin styles side by side

const Loud = (Base) => class extends Base {
  shout() { return this.speak().toUpperCase(); }
};

class Quiet { speak() { return 'hello'; } }
class Speaker extends Loud(Quiet) {}

console.log(new Speaker().shout());

const bundle = { greet() { return 'hi'; } };
class Plain {}
Object.assign(Plain.prototype, bundle);
console.log(new Plain().greet());
console.log(Object.getPrototypeOf(Speaker.prototype) !== Quiet.prototype);

Try copying Source.prototype with Object.assign and then with getOwnPropertyDescriptors. Only one of them brings the getter across.

Exercises

Compose behaviours instead of subclassing

Write composeBehaviours(...behaviours). It returns a create(initialState) function that: copies initialState into a fresh object, calls each behaviour with that object so methods can close over it, merges what each behaviour returns onto the object, and returns it. Later behaviours override earlier ones on a key clash. The initialState object passed in must never be mutated, and calling create twice must give two independent objects.

Inject the clock and the store

Write createUserService({ store, clock, nextId }) returning an object with add(name) and find(id). add builds { id: nextId(), name, createdAt: clock() } in that key order, passes that exact object to store.save(user), and returns it. find(id) returns store.get(id). A name that is not a non-empty string throws a TypeError before the store is touched. The service must never read the real clock or invent its own ids: everything comes from the injected dependencies.

Check yourself

What does this log?
[2, 4] — The override adds 2 for the batch, then super.addMany loops and calls this.add, which resolves to the override again and adds 1 per item. Two items counted twice gives 4. This is the fragile base class problem: the subclass had to know whether addMany reuses add.
Why does Object.assign(Target.prototype, Source.prototype) usually copy nothing?
Class methods are non-enumerable, and Object.assign copies own enumerable properties only — Class methods are deliberately non-enumerable so they stay out of for...in, spread and Object.keys. Object.assign therefore skips them. Use Object.defineProperties(Target.prototype, Object.getOwnPropertyDescriptors(Source.prototype)) when you need a faithful copy, including accessors.
Which of these is the strongest reason to inject a dependency rather than import it directly?
It lets the caller substitute a deterministic or in-memory version, which makes the unit testable without mocking machinery — The payoff is substitutability. A service that receives its clock, store and id generator can be tested by passing () => 1000 and a Map. The same seam later lets you swap the storage implementation without touching the logic.
You have a Report that needs the caching, retry and audit-logging behaviour that four other unrelated types also need. What is the sensible design?
Small composable units (mixins, wrappers or injected collaborators) applied per type as needed — Three optional capabilities across five types is eight possible combinations, which single inheritance cannot express without either a bloated base or a deep chain nobody can reason about. Independent units composed per type is the version that survives a fourth capability being added.

Common mistakes

  • Reaching for extends when the relationship is has-a rather than is-a.
  • Overriding a method purely to disable it, which is the signal that the base contract does not fit.
  • Expecting Object.assign to copy class methods or getters. It copies own enumerable properties and flattens accessors.
  • Assuming super works in an Object.assign mixin. It only works in class-factory mixins.
  • Letting mixin order be accidental. Later sources overwrite earlier ones, so the order is part of your API.
  • Reading the clock, the network or a global store inside a unit, then needing a mocking framework to test it.
  • Swinging to the other extreme and refusing to subclass Error, where one level of inheritance is exactly right.

Takeaways

  • Inheritance fixes one axis of variation forever; composition lets each object pick its abilities.
  • A behaviour factory closing over self needs no this, so its methods survive being passed around.
  • Object.assign mixins are cheap but skip non-enumerable methods and cannot use super; class-factory mixins can.
  • Injecting clocks, stores and id generators turns hard tests into ordinary function calls.
  • Duck typing means checking for the method you need rather than the class you expect.
  • Subclasses depend on base implementation details, which is why a harmless refactor can break them.
  • One level of inheritance for a real is-a relationship, such as an Error subclass, is still the right tool.