JavaScript Inheritance Patterns: Classes vs Composition vs Mixins

Link copied
JavaScript Inheritance Patterns: Classes vs Composition vs Mixins

JavaScript Inheritance Patterns: Classes vs Composition vs Mixins

"Favor composition over inheritance" has been the design-pattern gospel for 30 years. But in 2026 JavaScript, when should you actually use class extends? When is a mixin better? When does composition win? This article walks through the three patterns and shows when each is the right tool.

This is Part 13 of the JavaScript Fundamentals series. Part 12 on the prototype chain is the prerequisite — every inheritance pattern is built on it.

Pattern 1: Class inheritance (extends) #

The "OOP textbook" approach:

class Vehicle {
  constructor(maxSpeed) { this.maxSpeed = maxSpeed; }
  describe() { return `Max ${this.maxSpeed} mph`; }
}

class Car extends Vehicle {
  constructor(maxSpeed, doors) {
    super(maxSpeed);
    this.doors = doors;
  }
  describe() { return `${super.describe()}, ${this.doors} doors`; }
}

const c = new Car(120, 4);
c.describe(); // 'Max 120 mph, 4 doors'

When it shines #

  • The hierarchy is truly hierarchical: every Car IS a Vehicle. Substitution holds (Liskov).
  • You expect a relatively shallow chain (2-3 levels).
  • You want the polymorphism: vehicle.describe() dispatches correctly without if ladders.
  • You're modeling an external system that's already class-based (DOM nodes, framework base classes).

When it hurts #

  • The chain grows past 3-4 levels.
  • You find yourself adding methods to base classes "just for this subclass".
  • Two subclasses each need slightly different features — and your only tool is multiple inheritance (which JS doesn't have).
  • You override more than you reuse.
  • A common smell: if (this instanceof X) inside a base-class method. The hierarchy is wrong.

Pattern 2: Composition ("has-a") #

Instead of subclassing to add a behavior, hold an instance of the behavior as a field:

class Engine {
  start() { return 'vroom'; }
  stop()  { return 'silent'; }
}

class Car {
  constructor() {
    this.engine = new Engine();   // Car HAS an Engine, not IS an Engine
  }
  go() { return this.engine.start(); }
}

const c = new Car();
c.go(); // 'vroom'

More expressive variant — accept the dependency from outside (dependency injection):

class Car {
  constructor(engine) { this.engine = engine; }
  go() { return this.engine.start(); }
}

const gasCar = new Car(new GasEngine());
const evCar  = new Car(new ElectricEngine()); // same Car class, different behavior

Why composition wins #

  • You can mix capabilities freely without a rigid hierarchy.
  • Testing is easy: pass a mock engine.
  • Each piece is independently reusable.
  • No fragile-base-class problem.

The cost #

More typing. Each method that delegates to the composed object adds a forwarding line. Worth it almost every time.

Pattern 3: Mixins #

Mixins copy methods from one or more sources onto a target. They live between class inheritance and composition.

Object mixin (simple) #

const Swims = {
  swim() { return `${this.name} swims`; },
};
const Flies = {
  fly() { return `${this.name} flies`; },
};

class Duck {
  constructor(name) { this.name = name; }
}
Object.assign(Duck.prototype, Swims, Flies);

new Duck('Donald').swim(); // 'Donald swims'
new Duck('Donald').fly();  // 'Donald flies'

Function mixin (class factory) #

The modern pattern — a mixin is a function that takes a class and returns a subclass:

const Serializable = (Base) => class extends Base {
  toJSON() { return JSON.stringify(this); }
  static fromJSON(s) { return Object.assign(new this(), JSON.parse(s)); }
};

const Loggable = (Base) => class extends Base {
  log(msg) { console.log(`[${this.constructor.name}]`, msg); }
};

class User extends Loggable(Serializable(Object)) {
  constructor(name) { super(); this.name = name; }
}

const u = new User('Alice');
u.log('hello');     // [User] hello
u.toJSON();         // '{"name":"Alice"}'

The chain becomes: User → Loggable → Serializable → Object. Each mixin is a one-trick contribution; they stack cleanly because each one returns a new class extending the next.

When mixins are right #

  • You want opt-in cross-cutting concerns (logging, serialization, observability, dirty-tracking).
  • You're building a library and want consumers to be able to add features by extending.
  • The behavior is genuinely orthogonal (a User can be both Loggable AND Serializable AND Cacheable).

When mixins hurt #

  • The mixin reads or writes properties without declaring them — fragile.
  • Two mixins have a name collision and the later one silently overrides.
  • Method lookup is hard to trace through 5 layers.

Putting it together #

Real codebases use all three. A common pattern:

// Composition: a Repository HAS a Database connection
class UserRepository {
  constructor(db, logger) {
    this.db = db;
    this.logger = logger;
  }
  async findById(id) {
    this.logger.info({ msg: 'findById', id });
    return this.db.queryOne('SELECT * FROM users WHERE id = $1', [id]);
  }
}

// Class inheritance: an HTTPNotFoundError IS an HTTPError IS an Error
class HTTPError extends Error {
  constructor(status, message) { super(message); this.status = status; }
}
class HTTPNotFoundError extends HTTPError {
  constructor(resource) { super(404, `Not found: ${resource}`); this.resource = resource; }
}

// Mixin: turn any class into something Sentry-traceable
const Traceable = (Base) => class extends Base {
  traceId = crypto.randomUUID();
  trace(name) { return startSpan(name, { traceId: this.traceId }); }
};

Each pattern in its lane.

The interface-segregation principle (in plain JavaScript) #

Keep interfaces small. A 30-method base class is a smell. Two object types might share start() and stop() but disagree on everything else — that doesn't mean they should inherit from a common base. It means they happen to satisfy the same small interface.

JavaScript doesn't have explicit interfaces (TypeScript does), but you can document the duck-typed contract:

// Stoppable: any object with start() and stop() methods returning a Promise<void>
function lifecycle(thing) {
  if (typeof thing.start !== 'function' || typeof thing.stop !== 'function') {
    throw new TypeError('Expected start/stop');
  }
  return { start: () => thing.start(), stop: () => thing.stop() };
}

No inheritance, no mixin — just a contract. Many production systems work this way.

Why "favor composition" is dogma for a reason #

The three failure modes of class hierarchies show up in every codebase that overuses inheritance:

  1. Fragile base class — changing the parent breaks subclasses you forgot existed.
  2. God object — base class grows methods that only some subclasses need.
  3. Yo-yo problem — to trace one method call, you read 5 files going up and down the chain.

Composition does not eliminate complexity, but it puts the complexity in the call site (a this.engine.go()) instead of hidden in the chain. Easier to read, easier to test, easier to refactor.

Common gotchas #

Gotcha 1: instanceof doesn't work across class-factory mixins by name.

class A extends Loggable(Object) {}
const a = new A();
a instanceof Loggable; // ✗ Loggable is a function, not a class

Use duck typing or a Symbol-based brand: if (a[Symbol.for('Loggable')]) ....

Gotcha 2: Calling super in a multi-layer mixin chain. Each layer must remember to call super.method() if you want the parent's version to also run. Easy to forget in the middle.

Gotcha 3: Static methods don't inherit via mixin functions cleanly. If a mixin needs to attach static methods, it must do Object.assign(NewClass, { staticThing }) after building the subclass.

Gotcha 4: Composition + dependency injection makes constructors long.

new UserService(new Database(), new Logger(), new Cache(), new EventBus(), ...);

Use a DI container or a factory function. The pain you feel at construction time is the price for testability.

Recap #

Three inheritance patterns:

  1. Class inheritance (extends) — for true IS-A hierarchies, max 2-3 levels deep.
  2. Composition (HAS-A) — the default. Hold instances as fields; delegate. Wins almost every time.
  3. Mixins — for orthogonal cross-cutting concerns. Function-based class factories scale best.

Default to composition. Reach for class inheritance only when the IS-A relationship is genuine. Use mixins for behaviors you genuinely want to opt into.

Next up: Symbols — JavaScript's underused identifier type, why well-known Symbols power iterators, and the cases where Symbols beat strings.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *