DEV Community

loading...

JavaScript Inheritance: The Dark Arts

Nick Kelly
Web Application Developer
・12 min read

Inheritance remains one of the most relied upon and misunderstood features of JavaScript to this day. Since ES2015 JavaScript developers have been able to ignore how the inheritance sausage is made by relying on the class syntax that hides the nitty gritty details, until they run into its mind-bending edge cases.

In this post we'll explore the secrets of JavaScript inheritance: [[Prototype]] and constructors.

But first, put your knowledge to the test:

How many can you get right?

1. Overriding getters and setters

console.log('Overriding getters and setters');

class SuperClass {
  _value = undefined;
  get value() { return this._value; }
}
class SubClass extends SuperClass {
  set value(to) { this._value = to; }
}
const sub = new SubClass();
sub.value = 5;

// What gets logged?

console.log(sub.value); // undefined
Enter fullscreen mode Exit fullscreen mode

2. Deleting from a class instance

console.log('Deleting from a class instance');

class MyClass {
  fn1 = function() {}
  fn2() {}
}
const myInstance = new MyClass();

// What gets logged?

delete myInstance.fn1;
console.log(myInstance.fn1); // undefined

delete myInstance.fn2;
console.log(myInstance.fn2); // fn2() {}
Enter fullscreen mode Exit fullscreen mode

3. Deleting from an object

console.log('Deleting from an object');

const myObject = {
  fn() {},
  toString() {},
};

// What gets logged?

delete myObject.fn;
console.log(myObject.fn); // undefined

console.log(myObject.toString); // toString() {}
myObject.toString = undefined
console.log(myObject.toString); // undefined
delete myObject.toString;
console.log(myObject.toString); // toString() { [native code] }
Enter fullscreen mode Exit fullscreen mode

4. Overriding constructors???

class MyClass {
  constructor() {
    console.log("Original Consturctor");
  }
}

MyClass.prototype.constructor = function Overridden() {
  console.log("Overridden Constructor");
}

// What gets logged?

const instance = new MyClass(); // "Original Constructor"
console.log(instance.constructor.name);  // Overridden
console.log(instance.constructor.prototype === Object.getPrototypeOf(instance)); // false
Enter fullscreen mode Exit fullscreen mode

If you got all of the above right then maybe you're already grizzled JavaScript veteran and know all the ins and outs of OOJS (Object Oriented JavaScript).

For the rest of us, it's time to open Pandora's Box.

Inheritance

In OOP (Object Oriented Programming), inheritance is the mechanism used build a new object or class ontop another object or class.

JavaScript has inheritance but doesn't have static "classes" like static OO languages (C++, C#, Java). Instead, JavaScript links objects together by prototypes. Even in ES2015, class is mostly just syntactic sugar for objects with prototypal relationships.

At a glance, OOJS using class appears sane.

class Base {
  prop = 'hello world';
}
class Sub extends Base {
  //
}
const sub = new Sub();
// sub has access to properties on base
console.log(sub.prop);  // "hello world"
Enter fullscreen mode Exit fullscreen mode

But how does this really work? What is a "class" and how does sub have access to prop?

Enter: [[Prototype]]

JavaScript uses prototypes to achieve inheritance. All objects have a [[Prototype]] internal slot which is the object being inherited from. Internal slots are internal to the JavaScript interpreter. Some internal slots are exposed via functions like Object.getPrototypeOf() and many aren't exposed at all.

An object's [[Prototype]] can be null or another object which itself has a [[Prototye]] slot. An object's linked list of [[Prototype]]s (i.e. myObject.[[Prototype]].[[Prototype]].[[Prototype]]...) is called its "prototype chain" and terminates with null.

To lookup a property on an object the JavaScript interpreter performs a lookup on the top-level object, then that object's [[Prototype]], then [[Prototype]].[[Prototype]], and so on until reaching null.

We can use Object.create(proto) to create a new object with proto as its [[Prototype]] and use Object.getPrototypeOf(obj) to get the [[Prototype]] of an object obj

const ancestor = Object.create(null);
const parent = Object.create(ancestor);
const child = Object.create(parent);

// child inherits from parent
console.log(Object.getPrototypeOf(child) === parent); // true
// parent inherits from ancestor
console.log(Object.getPrototypeOf(parent) === ancestor); // true
// ancestor inherits nothing
console.log(Object.getPrototypeOf(ancestor) === null); // true
Enter fullscreen mode Exit fullscreen mode

We can also use Object.setPrototypeOf(sub, base) to change the [[Prototype]] of an object sub to another object (or null), base. Notice - unlike static OO languages we can dynamically change inheritance heirarchies at runtime! For performance reasons this is strongly advised against. According to Benedikt Muerer of v8, a every time you change the prototype chain, a kitten dies.

const base = { prop: 'hello world' };
const sub = {};
console.log(sub.prop); // undefined
Object.setPrototypeOf(sub, base);
console.log(sub.prop); // "hello world"
Object.setPrototypeOf(sub, null);
console.log(sub.prop); // undefined
Enter fullscreen mode Exit fullscreen mode

Objects created using the object literal syntax {} inherit from JavaScript's base Object.prototype which in-turn inherits from null.

const obj = {};
console.log(Object.getPrototypeOf(obj) === Object.prototype); // true
console.log(Object.getPrototypeOf(Object.prototype) === null); // true
Enter fullscreen mode Exit fullscreen mode

Functions

Functions are a regular JavaScript objects, but with additional internal slots. Like regular objects they have properties and a [[Prototype]] internal slot, but unlike other objects they are callable thanks to a [[Call]] internal method.

Constructors are functions with some specific attributes.

Enter: Constructors

Constructor functions compliment prototypes by making prototype configuration and object creation and inialisation easy and consistent. Inheritance can still be achieved without constructors (for example with Object.create) but it's less common.

Any non-arrow function (any function created with the function keyword) can be used as a constructor. All non-arrow functions have a prototype property, initialized to a new object with only one property prototype.constructor whose value is the constructor function. Note that a function's prototype property is NOT the same as that functions [[Prototype]] internal slot.

Constructors have to be called with a the new operator (unless being used within another constructor function for inheritance) for the this variable to be created and bound correctly. The this object's [[Prototype]] is set to the constructors prototype property.

It's good practice to begin constructor names with an uppercase character so you know to call them with new.

function Constructor() {} 
console.log(Constructor.prototype); // { constructor: f }
const instance = new Constructor();
console.log(Object.getPrototypeOf(instance) === Constructor.prototype) // true
// i.e. instance.[[Prototype]] === Constructor.prototype
Enter fullscreen mode Exit fullscreen mode

When called with new, construtors implicitly return their this object.

let this_ref;

function Constructor() {
  console.log(Object.getPrototypeOf(this) === Constructor.prototype); // true
  this_ref = this;
  // implicitly returns `this`
}

const that = new Constructor();
console.log(that === this_ref); // true;
Enter fullscreen mode Exit fullscreen mode

"classes" created with the ES2015 (e.g. class MyClass {...}) are also simply constructor functions (typeof MyClass === 'function') but whose internal slots are configured differently, such as [[IsClassConstructor]] that causes classes to throw a TypeError if called without the new operator, unlike constructor functions not created with the class syntax.

Given that instances created with the new operator inherit from their constructors prototype property, we can create functions on the prototype property that will be inherited by the instances.

function Person() {
  //
}

Person.prototype.sayHello = function() {
  console.log('hello');
}

const person = new Person();
person.sayHello();  // 'hello'
Enter fullscreen mode Exit fullscreen mode

ES2015 classes without ES2015 syntax

Now that we know about prototypes and constructors we can replicate the ES2015 class functionality with constructor functions and prototypes.

Using constructor-prototype syntax we have enormous flexibility in how we glue together our objects at the price of having to glue them together manually.

We can manually accomplish what the ES2015 class syntax does for us by maintaining the following:

  • Instance prototype chain: SubClass.prototype.[[Prototype]] must be set to SuperClass.prototype. This sets up the prototype chain of instances constructed from new SubClass(...) such that:
    • subclass_instance.[[Prototype]] === SubClass.prototype
    • subclass_instance.[[Prototype]][[Prototype]] === SuperClass.prototype
    • subclass_instance.[[Prototype]][[Prototype]][[Prototype]] === Object.prototype
    • subclass_instance.[[Prototype]][[Prototype]][[Prototype]][[Prototype]] === null
  • Constructor prototype chain: SubClass.[[Prototype]] must be set to SuperClass. This means the SubClass function inherits "static" properties from SuperClass (properties on the SuperClass constructor function) such that:
    • SuperClass.staticProperty = 5
    • SubClass.staticProperty === 5
  • Initialisation: When the SubClass constructor is called with new, it needs to immediately call the SuperClass constructor function binding its this value (SuperClass.call(this, ...)), in order to initialise SuperClass on this properly.
    • The ES2015 class syntax forces us to call the super constructor using super() at the beginning of our subclasses constructor function, or else the interpreter will throw an error. This is not forced in constructor-prototype syntax so we need to remember it ourselves! Otherwise our class instances will not be properly initialised.

Our object relations for the model described above are:

Inheritance Relations

Don't be intimidated by the number of objects and connections - if you can grok the diagram then you can derive an understanding of everything relating OOJS.

The super Problem

The only class functionality we can't exactly replicate with constructors and prototypes is super.

function Base() {}
Base.prototype.fn = function() {
  console.log('base');
}

function AnotherBase() {}
AnotherBase.prototype.fn = function() {
  console.log('another base');
}

function Sub() {}
Object.setPrototypeOf(Sub, Base);
Sub.prototype.fn = function() {
  console.log('sub');
  // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
  // "super" call, hardcoded to `Base`
  // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
  Base.prototype.fn.call(this);
}

const sub = new Sub();

sub.fn();
// sub
// base

Object.setPrototypeOf(Sub, AnotherBase);
Object.setPrototypeOf(Sub.prototype, AnotherBase.prototype);
sub.fn();
// sub
// base
Enter fullscreen mode Exit fullscreen mode

Without referencing the superclass, Base, directly we have no way to determine where the current method under invocation sits in the prototype chain, and therefore can't lookup functions that are strictly higher in the prototype chain (i.e. a super call).

By referencing Base directly in an attempt to replicate super, we've destroyed our ability to safely change the prototype since our "super" call would be referencing a function we no longer inherit.

With ES2015, we have a super keyword that still works when we reassign [[Prototype]]

class Base {
  fn() {
    console.log('base');
  }
}

class AnotherBase {
  fn() {
    console.log('another base');
  }
}

class Sub extends Base {
  fn() {
    console.log('sub');
    super.fn();
  }
}

const sub = new Sub();

sub.fn();
// sup
// base

Object.setPrototypeOf(Sub, AnotherBase);
Object.setPrototypeOf(Sub.prototype, AnotherBase.prototype);

sub.fn();
// sup
// another base
Enter fullscreen mode Exit fullscreen mode

Pre ES2015 classes by example

We'll code a simple inheritance example of 2 classes: a superclass Animal and subclass Dog using the relations described above. Each inheritance layer has 3 associated objects: the constructor function, prototype object and instance object.

Our domain is:

Animal-Dog-Uml

In JavaScript, our objects will be:

Animal-Dog-Inheritance-Relations

/**
 * @constructor Animal
 * @abstract
 *
 * @param {number} legs
 */
function Animal(legs) {
  this.legs = legs;
}

/**
 * Abstract static property on Animal constructor
 * to be overridden by a property the subclasses constructor
 *
 * @abstract
 * @static
 * @type {string}
 */
Animal.species = undefined;

/**
 * getter on the animal prototype that retrieves the static, overridden
 * property from the subclasses constructor, `species`
 * 
 * @readonly
 * @type {string}
 * 
 * @example
 * const dog = new Dog()
 * dog.species; // calls `Animal.prototype.species` -> `Dog.species`
 */
Object.defineProperty(Animal.prototype, 'species', {
  enumerable: true,
  configurable: false,
  /** @returns {string} */
  get() {
    // alternatively, `const SubClass = this.constructor`
    const SubClass = Object.getPrototypeOf(this).constructor;
    return SubClass.species;
  },
})

/**
 * Method on the Animal prototype, inherited by animal instances and subclasses
 * of Animal
 *
 * @param {string} food
 */
Animal.prototype.eat = function(food) {
  console.log(`Yum! eating ${food}`);
}


/**
 * @constructor Dog
 *
 * Subclass of Animal
 */
function Dog() {
  const legs = 4;

  // we run the inherited constructor, bound to `this`, to initialise our superclass properly
  // this effectively "subtypes" `this` to an instance of the superclass (`this` becomes a superset of the superclasses instances type)

  Animal.call(this, legs);
}

// Inherit staticically from Animal
Object.setPrototypeOf(Dog, Animal);

// Inherit prototype from Animal
Object.setPrototypeOf(Dog.prototype, Animal.prototype);

/**
 * @override
 * @type {string}
 */
Dog.species = 'Dog';

/**
 * Override the `eat` method from `Animal.prototype`
 * Also call the super method
 *
 * @override
 *
 * @param {*} food
 */
Dog.prototype.eat = function(food) {
  console.log('Woof!');

  // super call!
  Animal.prototype.eat.call(this, food);
}

const dog = new Dog();

dog.eat('chicken');
// 'Woof!'
// 'Yum! eating chicken'

console.log(dog.species);
// 'Dog'
Enter fullscreen mode Exit fullscreen mode

Access to inherited properties

One of the most important things to understand when working directly with prototypes is how accessors and operators propagate. Of the following actions, only the get accessor propagates up the prototype chain.

accessor or operator propagates up the prototype chain
get yes
set no
delete no
const base = { prop: 'hello', ref: {} };
const sub = {};
Object.setPrototypeOf(sub, base);
console.log(sub.prop); // 'hello'

// the `delete` operator does not propagate

// calling delete on `prop` can have no effect on objects in its prototype chain
delete sub.prop;
console.log(sub.prop); // 'hello'

// similarly, the `set` accessor does not propagate
console.log(sub.ref === base.ref); // true
base.ref = { a: 'different', object: true };
console.log(sub.ref === base.ref); // true
sub.ref = { something: 'else' };
console.log(sub.ref === base.ref); // false
Enter fullscreen mode Exit fullscreen mode

Who cares?

Most JavaScript application developers don't need to know its inheritance mechanism in great detail. Some of JavaScript's most flexible features, including prototype hacking, are considered footgun's to be avoided. If you feel the need to hack a prototype chain you're probably better off finding another way.

Knowing about prototypes is more important when working in the broader ecosystem with packages or tooling or when monkeypatching libraries (modifying prototypes of objects from third party libraries).

How does TypeScript fit into this?

Unfortunately, like a square peg into a round hole.

TypeScript doesn't attempt to model the fine details of OOJS. It doesn't differentiate between properties on a class instance and properties on a classes prototype.

class MyClass {
  instanceProperty: number;
  prototypeProperty() {};
  constructor() { this.instanceProperty = 5; }
}

// TypeScript sees instances of MyClass as equivalent to:
interface MyClassInstance {
  instanceProperty: number;
  prototypeProperty() {};
}
// properties of the prototype and instance are merged together
Enter fullscreen mode Exit fullscreen mode

Moreover, TypeScript doesn't even allow adding new signature to a constructor function.

const MyConstructor: { new(): {} } = function() {}
// Type '() => void' is not assignable to type 'new () => {}'.
Enter fullscreen mode Exit fullscreen mode

To use TypeScript on constructor functions have to resort to the unsafe as unknown hack. The language server also won't tell us when our prototype is missing properties

interface MyInstanceAndPrototype {
  //
  methodOnPrototype() {};
}

interface MyConstructor extends Function {
  new(): MyInstanceAndPrototype;
  prototype: MyInstanceAndPrototype;
}

const MyConstructor = function MyConstructor() {} as unknown as MyConstructor

// Forgot to add `MyConstructor.prototype.methodOnPrototype`?
// There won't be any TypeScript error
Enter fullscreen mode Exit fullscreen mode

Revisiting our examples

With our understanding of prototypes, constructors and property access, we can revisit our and understand initial examples

Explanation: 1. Overriding getters and setters

console.log('Overriding getters and setters');

class SuperClass {
  _value = undefined;
  get value() { return this._value; }
}
class SubClass extends SuperClass {
  set value(to) { this._value = to; }
}
const sub = new SubClass();
sub.value = 5;

// What gets logged?

console.log(sub.value); // undefined
Enter fullscreen mode Exit fullscreen mode

What went wrong?

Writing this in pre-ES2015 syntax we have something close to:

console.log('Overriding getters and setters');

function SuperClass() {
  this._value = undefined;
}
Object.defineProperty(SuperClass.prototype, 'value', {
  get() { return this._value },
})

function SubClass() {}

Object.setPrototypeOf(SubClass, SuperClass);
Object.setPrototypeOf(SubClass.prototype, SuperClass.prototype);

Object.defineProperty(SubClass.prototype, 'value', {
  set(to) { this._value = to; },
});

const sub = new SubClass();

sub.value = 5;

// What gets logged?

console.log(sub.value); // undefined
Enter fullscreen mode Exit fullscreen mode

Notice we have both SubClass.prototype.value and SuperClass.prototype.vaue.
SubClass.prototype.value overrides SuperClass.prototype.value. SubClass.prototype.value has a setter with NO GETTER!! When we read sub.value, we accessing SubClass.prototype.value which has no getter and a value of undefined by default, and therefore returns undefined. We never reach SuperClass.prototype.value! This issue once cost me 4 hours in debugging hell.

Explanation: 2. Deleting from a class instance

console.log('Deleting from a class instance');

class MyClass {
  fn1 = function() {}
  fn2() {}
}
const myInstance = new MyClass();

// What gets logged?

delete myInstance.fn1;
console.log(myInstance.fn1); // undefined

delete myInstance.fn2;
console.log(myInstance.fn2); // fn2() {}
Enter fullscreen mode Exit fullscreen mode

Writing this in pre-ES2015 syntax we have something close to:

console.log('Deleting from a class instance');

function MyClass() {
  this.fn1 = function() {};
}
MyClass.prototype.fn2 = function fn2() {}

const myInstance = new MyClass();

// What gets logged?

delete myInstance.fn1;
console.log(myInstance.fn1); // undefined

delete myInstance.fn2;
console.log(myInstance.fn2); // fn2() {}
Enter fullscreen mode Exit fullscreen mode

Notice that with class syntax, setting property = ... within the class body is roughly equivalent setting this.property = ... within the classes constructor. It places the property on the class instances.

Conversely, fn2() {} within the class body adds that function to the classes prototype MyClass.prototype.

The delete operator does not propagate up the prototype chain. Therefore we delete fn1 since its on the class instance, but not fn2 since it's on the class prototype.

Explanation: 3. Deleting from an object

console.log('Deleting from an object');

const myObject = {
  fn() {},
  toString() {},
};

// What gets logged?

delete myObject.fn;
console.log(myObject.fn); // undefined

console.log(myObject.toString); // toString() {}
myObject.toString = undefined
console.log(myObject.toString); // undefined
delete myObject.toString;
console.log(myObject.toString); // toString() { [native code] }
Enter fullscreen mode Exit fullscreen mode

Similar to 2., but now we have an object instance myObject with two functions. All objects created with the literal syntax {} have their [[Prototype]] equal to Object.prototype. Object.prototype has a toString method.

In our example:

  • we override Object.prototype.toString in the assignment of myObject.
    • logging myObject.toString prints our overridden copy, toString() {}
  • we set myObject.toString = undefined, which continues to override Object.prototype.toString but now with a value of undefined.
    • logging myObject.toString prints our overridden copy, undefined
  • we delete toString from myObject. now toString calls will propagate up the prototype chain.
    • logging myObject.toString prints Object.prototype.toString.

Explanation: 4. Overriding constructors???

class MyClass {
  constructor() {
    console.log("Original Consturctor");
  }
}

MyClass.prototype.constructor = function Overridden() {
  console.log("Overridden Constructor");
}

// What gets logged?

const instance = new MyClass(); // "Original Constructor"
console.log(instance.constructor.name);  // "Overridden Constructor"
console.log(instance.constructor.prototype === Object.getPrototypeOf(instance)); // "false"
Enter fullscreen mode Exit fullscreen mode

This example is bogus. A special place in hell is reserved for people who reassign Constructor.prototype.constructor.

  • Constructors have a prototype property which becomes their instances [[Prototype]] internal slot.
  • The prototype initially has a single property, constructor, which points back to the original constructor function.
  • The Constructor.prototype.constructor is useful to superclasses to create new instances of this's class.

For example, here's a Container class that is safe to extend and still call clone() on:

function Container(items) {
  this.items = items;
}
Container.prototype.clone = function() {
  // we rely on prototype.constructor not being overridden
  return new (Object.getPrototypeOf(this).constructor)([...this.items]);
}

function UserContainer(users) {
  Container.call(this, users);
}
Object.setPrototypeOf(UserContainer, Container);
Object.setPrototypeOf(UserContainer.prototype, Container.prototype);
UserContainer.prototype.logoutAll = function() { /** ... */ }

const users = new UserContainer([]);
const users2 = users.clone();
console.log(users2 instanceof UserContainer); // true
Enter fullscreen mode Exit fullscreen mode

As far as I'm aware there's no good reason to ever change prototype.constructor, other than as a good April Fools joke.

Further Reading

  • Older libraries like express still use prototypes and constructors. Check out Express.Request for an example. Express uses Object.create() to use blueprint objects, req and res, as the [[Prototype]]s for the req and res of a request instance.

Discussion (4)

Collapse
webduvet profile image
webduvet • Edited

What is wrong with example 4?
constructor of MyClass does not sit in MyClass.prototype. what a surprise that it does not get overriden...
I noticed that you are saying it is a bogus example. I just felt that unlike the other examples where it might look surprising to some the No.4 is just plain error.

Collapse
nickkelly314 profile image
Nick Kelly Author • Edited

The confusing part is this:

instance.constructor.prototype === Object.getPrototypeOf(instance); // false
Enter fullscreen mode Exit fullscreen mode

Which makes sense once you know that instance.constructor is accessing an altered constuctor property from the prototype, but otherwise might be quite confusing.

You're right that it probably doesn't belong with the other examples. Hopefully JavaScript developers unaware of prototype.constructor might find it interesting.

Collapse
urielsouza29 profile image
Uriel dos Santos Souza

"Since ES6 appeared and the ability to add "classes" I see that it has only caused a confusion in the way of programming, making a javascript code as a migration from java." by dev.to/damxipo/functional-programm...

Collapse
elabftw profile image
eLabFTW

Thanks for this post, very complete and informative!