Inheritance vs composition
Build classes on other classes with extends and super, subclass Error and Array, add behaviour with mixins, and decide when composing small parts is the better design.
What you will learn
- Write a subclass with extends and super, and override a method
- Create a custom error class that works with instanceof and cause
- Combine behaviour with a mixin function or with plain composition
- Choose between inheritance and composition for a design
On this page
One class can be built on another. The new class, the subclass, gets everything the superclass has, and adds or changes what it needs. In JavaScript this is the prototype chain from lesson 2 wired up for you: the prototype of the subclass’s prototype is the superclass’s prototype. Inheritance is a strong tool and an easy one to overuse, so the second half of this lesson looks at the alternative, composition, and at how to choose.
extends and super
extends names the superclass. Inside the subclass, super(...) calls the superclass constructor, and super.method(...) calls a method of the superclass.
class Animal { constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }}
class Dog extends Animal { constructor(name, breed) { super(name); this.breed = breed; }
speak() { return `${super.speak()}: woof`; }}Dog calls super(name) so that Animal can set up name, then adds its own breed. Its speak replaces Animal’s, which is overriding, and it still reuses the original through super.speak(). If a subclass has no constructor, JavaScript supplies one that passes all arguments to super.
const rex = new Dog("Rex", "collie");console.log(rex.speak());console.log(rex instanceof Dog, rex instanceof Animal);console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype);console.log(rex);Rex makes a sound: wooftrue truetrueDog { name: 'Rex', breed: 'collie' }instanceof follows the chain, so rex is both a Dog and an Animal. The third line is the lesson 2 chain with a name: the prototype of Dog.prototype is Animal.prototype. The log shows only the own properties.
The rules of super
Two rules trip people up, and both follow from how objects get built.
class Base { static create() { return new this(); }}
class Derived extends Base { constructor() { try { this.ready = true; } catch (error) { console.log(error.name); } super(); this.ready = true; }}
console.log(Derived.create() instanceof Derived);console.log(Object.getPrototypeOf(Derived) === Base);ReferenceErrortruetrue- A subclass constructor must call
super()before it touchesthis. In a subclass, the object is created by the superclass constructor, so beforesuper()there is nothis. Thetryblock shows that reading it first throws aReferenceError. - Static members are inherited too. The class
Deriveditself hasBaseas its prototype, soDerived.create()finds the static method onBase. Inside it,thisis the class the method was called on, sonew this()builds aDerived, not aBase.
Inside a constructor, new.target is the class that new was called on. It is undefined when the function was called without new. A base class can compare it with itself to refuse direct use, which gives JavaScript the “abstract class” pattern:
class Shape { constructor() { if (new.target === Shape) { throw new TypeError("Shape is abstract: extend it"); } console.log("building a", new.target.name); }}
class Dot extends Shape {}
console.log(new Dot() instanceof Shape);try { new Shape();} catch (error) { console.log(error.message);}building a DottrueShape is abstract: extend itWhen Dot runs the Shape constructor through super(), new.target is still Dot, the class that new named. Only new Shape() itself matches the check.
Subclassing built-ins
You can extend Error, Array, Map and other built-in classes. The most common case by far is Error, so that callers can tell your failures apart with instanceof.
class ValidationError extends Error { constructor(field, message, options) { super(message, options); this.name = "ValidationError"; this.field = field; }}The constructor passes the message and the options object to Error, which is how the cause option from lesson 15 is supported. It then sets name, which controls how the error prints, and a field of its own.
function parseAge(text) { const age = Number(text); if (!Number.isInteger(age)) { throw new ValidationError("age", `not a whole number: ${text}`); } return age;}
try { parseAge("4.5");} catch (error) { console.log(error instanceof ValidationError, error instanceof Error); console.log(error.name, error.field, error.message); console.log(String(error));}true trueValidationError age not a whole number: 4.5ValidationError: not a whole number: 4.5Catching code can use instanceof ValidationError to handle this failure and let others pass, and still read the standard message. Because ValidationError is an Error, it also records a stack trace.
Subclassing Array works, and the array methods that make new arrays, such as map and filter, return instances of your subclass:
class Stack extends Array { peek() { return this.at(-1); }}
const stack = new Stack();stack.push(1, 2, 3);console.log(stack.length, stack.peek(), Array.isArray(stack));
const doubled = stack.map((n) => n * 2);console.log(doubled instanceof Stack, doubled.peek());console.log(stack);3 3 truetrue 6Stack(3) [ 1, 2, 3 ]The new array is built from the class stored under Symbol.species, which defaults to your subclass. The symbols lesson shows how to change that. Reach for this only when you need a real array with extra methods. In most programs a function that takes an array, or a class that holds an array in a private field, is simpler.
Mixins
JavaScript classes have a single superclass. A mixin is a function that takes a class and returns a subclass with extra behaviour, so features can be stacked.
const Serializable = (Base) => class extends Base { serialize() { return JSON.stringify(this); } };
const Comparable = (Base) => class extends Base { compareTo(other) { return this.value - other.value; } };A plain Item class gets both features when it is wrapped in the two mixins:
class Item { constructor(value) { this.value = value; }}
class Product extends Serializable(Comparable(Item)) {}
const a = new Product(3);console.log(a.serialize(), a.compareTo(new Product(1)));console.log(a instanceof Item);{"value":3} 2trueEach mixin is an arrow function that returns a class expression extending whatever it is given. Product ends up with the methods of Comparable, Serializable and Item, in a chain of three prototypes. Mixins work, but each one deepens the chain, and a name clash between two of them is resolved silently by order. Treat them as a tool for a few cross-cutting features, not as a default.
Composition
Inheritance models “is a”: a Dog is an Animal. Composition models “has a” or “can do”: an object is made by putting parts together. The question is which one keeps your code easier to change.
Inheritance fails when the real world does not fit a tree. Say a Bird class gets a fly method, and then you need a penguin. A penguin is a bird that cannot fly, so you either break the promise that every Bird can fly or restructure the whole hierarchy. With composition, abilities are small pieces of behaviour that you attach where they apply:
const canSwim = (self) => ({ swim: () => `${self.name} swims`,});
const canFly = (self) => ({ fly: () => `${self.name} flies`,});
function createDuck(name) { const self = { name }; return Object.assign(self, canSwim(self), canFly(self));}
function createPenguin(name) { const self = { name }; return Object.assign(self, canSwim(self));}Create one of each and ask what they can do:
const duck = createDuck("Duck");const penguin = createPenguin("Pingu");console.log(duck.swim(), duck.fly());console.log(penguin.swim(), penguin.fly);Duck swims Duck fliesPingu swims undefinedcanSwim and canFly return objects of methods that use the creature they were given. Object.assign copies them onto the new object, so each factory lists what that creature can do. The penguin was just never given fly, and nothing else had to change.
A rule of thumb that holds up in practice:
- Use inheritance when the subclass really is a more specific kind of the superclass, anywhere the superclass is expected the subclass works, and you control both. Errors are the textbook case.
- Prefer composition when you are mainly reusing code, when behaviours vary independently, or when the hierarchy would be more than two levels deep.
- Whichever you choose, keep the public surface small and the classes short.
Common pitfalls
- Touching
thisbeforesuper(). It throws aReferenceError. Callsuper()first. - Forgetting to pass the message or options to
superin an error subclass. The message is empty andcauseis lost. - Forgetting to set
name. The error prints as a plainError, which makes logs harder to read. - Overriding a method with a different meaning. If
Squarechanges whatarea()returns, code that works withRectanglebreaks. Subclasses must keep the promises of their parent. - Deep hierarchies. Each level makes a change ripple further. If you are past two levels, look at composition.
- Subclassing to reuse a few lines. If it is not an “is a” relationship, call a function or hold the other object in a field instead.
Exercises
Write a base class Shape with a getter name that returns the class name in lower case (this.constructor.name.toLowerCase()), an area() method that throws an Error, and a describe() method that returns "<name> area <area with 2 decimals>". Add subclasses Circle(radius) and Rectangle(width, height) that override area(), and Square(side) that extends Rectangle. Print the description of new Circle(2), new Rectangle(3, 4) and new Square(3), one per line. The expected output is circle area 12.57, rectangle area 12.00 and square area 9.00.
Show solution
describe() is written once on Shape and calls this.area(), which finds whichever override the instance has. Square needs no area() of its own, since passing the side twice to Rectangle is enough.
class Shape { get name() { return this.constructor.name.toLowerCase(); }
area() { throw new Error("area() must be overridden"); }
describe() { return `${this.name} area ${this.area().toFixed(2)}`; }}A circle supplies its own area():
class Circle extends Shape { constructor(radius) { super(); this.radius = radius; }
area() { return Math.PI * this.radius ** 2; }}So does a rectangle:
class Rectangle extends Shape { constructor(width, height) { super(); this.width = width; this.height = height; }
area() { return this.width * this.height; }}A square is a rectangle with equal sides. The loop shows all three shapes describing themselves through the inherited describe():
class Square extends Rectangle { constructor(side) { super(side, side); }}
for (const shape of [new Circle(2), new Rectangle(3, 4), new Square(3)]) { console.log(shape.describe());}circle area 12.57rectangle area 12.00square area 9.00Write the same program without classes or extends. Make factory functions circle(radius), rectangle(width, height) and square(side) that return objects with a name and an area() function, and one standalone function describe(shape). Build square out of rectangle. The output must match the previous exercise: circle area 12.57, rectangle area 12.00 and square area 9.00.
Show solution
Each factory returns a plain object that carries its own area. square spreads the result of rectangle(side, side) and replaces the name. The function describe works with anything that has a name and an area, so it needs no common parent.
function circle(radius) { return { name: "circle", area: () => Math.PI * radius ** 2 };}
function rectangle(width, height) { return { name: "rectangle", area: () => width * height };}
function square(side) { return { ...rectangle(side, side), name: "square" };}
function describe(shape) { return `${shape.name} area ${shape.area().toFixed(2)}`;}
for (const shape of [circle(2), rectangle(3, 4), square(3)]) { console.log(describe(shape));}circle area 12.57rectangle area 12.00square area 9.00Write HttpError, a subclass of Error with a status property, and NotFoundError, a subclass of HttpError that takes a path and uses status 404 and the message "no page at <path>". Both set their own name and pass the options through to super. Write load(path), which catches an inner Error("disk read failed") and rethrows a NotFoundError with the inner error as its cause. Call load("/missing") in a try, and in the catch print whether the error is an instance of NotFoundError and of HttpError on one line, then "<name> <status>: <message>", then the message of the cause. The expected output is true true, NotFoundError 404: no page at /missing and disk read failed.
Show solution
NotFoundError supplies the status and the message and hands the options on, so the cause reaches Error. Each class sets name after super, because super has already created the object.
class HttpError extends Error { constructor(status, message, options) { super(message, options); this.name = "HttpError"; this.status = status; }}
class NotFoundError extends HttpError { constructor(path, options) { super(404, `no page at ${path}`, options); this.name = "NotFoundError"; }}Throw one of these errors from a catch block, so that the original error travels along as its cause:
function load(path) { try { throw new Error("disk read failed"); } catch (cause) { throw new NotFoundError(path, { cause }); }}
try { load("/missing");} catch (error) { console.log(error instanceof NotFoundError, error instanceof HttpError); console.log(`${error.name} ${error.status}: ${error.message}`); console.log(error.cause.message);}true trueNotFoundError 404: no page at /missingdisk read failedSummary
class B extends AmakesB.prototypeinherit fromA.prototypeandBinherit the static members ofA.super(...)runs the parent constructor and must come beforethis.super.method()calls the parent’s version.- Override a method by defining it again. Keep the parent’s promises.
- Extend
Errorfor custom failures: passmessageandoptionstosuper, and setname. - A mixin is a function from a class to a subclass. It is a way to stack features, at the cost of a longer chain.
- Composition builds objects from small parts. Prefer it when behaviours vary independently or when the hierarchy does not fit a tree.
Next: property attributes and immutability, the settings behind every property.