Classy coding - JavaScript OOP through the ages

đŸŒ±August 14, 2024.
budding 🌿
23 minutes read ⏱

This blog post was originally presented at SeattleJS in August 2024.

Constructing a class in JavaScript

Object-Oriented Programming (OOP) has been around for a long time and in many languages. JavaScript is no exception to that, which should come as no surprise to anyone using JS today. It hasn’t always been a first-class paradigm in the language, though.

Let’s embark on a journey to learn about how OOP has been done throughout JavaScript’s history, with our destination set on understanding how we should do it today and what all the modern syntax is doing for us under the hood.

The prototype chain

Imagine a classic OOP problem - you have a bunch of objects that share an “is a” relationship. This “is a” relationship is typically used to explain when multiple objects should share a “class” type. I’ll use rectangles to keep it easy. So we have a bunch of objects, each of which “is a” rectangle - how do we represent that so and give each rectangle instance the interface a rectangle class should have?

To start, let’s make a rectangle instance. Write or paste this code into your browser console to make one:

let rect1 = {width: 4, height: 5};
rect1

The second line should cause the console to display a reference to the rect1 object. You may have noticed that both properties fit in a single line, but the dev tools still give you an option to expand the object. Isn’t that weird?

The chrome dev tools showing the rectangle's width and height properties and values, as well as an arrow icon to expand the view.

If my object only those two properties and they both fit on screen, why would I need to expand it?

As you’ll quickly find, there’s my height and width properties, as well as some weird [[Prototype]] object. Continuing to expand the [[Prototype]] object reveals a few more things. Some of them, like toString() and hasOwnProperty() probably look familiar. Others, such as isPrototypeOf() may not be.

You see, JavaScript has an unusual approach to inheritance called a prototype chain. It’s a linked list of object instances. Each instance makes up one part of the object. I think it’s easier to understand interactive exploration, so crack open your browser dev console or favorite JS REPL and let’s explore!

The special [[Prototype]] property on this object by default is called the object prototype. Whenever you create an object in JS, the object prototype is added to your object’s prototype chain automatically. You can also access it without dev tools by using an arcane incantation: rect1.__proto__. Entering that in the dev tools console will show the same thing as expanding the [[Prototype]] object, and that’s because it is the same object.

But how does this relate to our rectangle? Well it turns out that you can specify a custom prototype object. Think of prototype objects as a “class” or “class type” from other languages. First, let’s define the functionality of our rectangle class:

const baseRectangle = {
	isSquare: function() { return this.width === this.height },
	area: function() { return this.width * this.height }
}

Now I can add this to the prototype chain for rect1:

rect1.__proto__ = baseRectangle;

When accessing a property on an object, if the object doesn’t have the property then the JS runtime will look for it further down the prototype chain (on the __proto__ property) of the object. For example, accessing rect1.isSquare():

  1. The JS runtime checks the rect1 object, which only has width, height, and __proto__ properties.
  2. The runtime checks the object at __proto__ for isSquare and finds it. Done!

As an aside, it’s also worth understanding the chain part of the prototype chain. rect1 isn’t the only object so far whose prototype was set to the object prototype when created: baseRectangle’s was too! Even though I’ve changed the prototype for rect1, I can still use methods from the prototype object on it. For example, if I want to check if rect1.isPrototypeOf(baseRectangle), that will work! The JS runtime resolves the call like this:

  1. It checks rect1 for an isPrototypeOf property and fails to find it.
  2. It then checks for rect1.__proto__ which exists, so it checks rect1.__proto__ for isPrototypeOf.
  3. rect1.__proto__ has no isPrototypeOf method, so it checks if rect1.__proto__.__proto__ exists. Since it does, it continues searching the prototype chain
  4. rect1.__proto__.__proto__ does have an isPrototypeOf method, so that gets resolved as the rect1.isPrototypeOf() method and is invoked.
  5. Extra credit: The function call checks if rect1 === baseRectangle.__proto__ and returns false. baseRectangle is the prototype of rect1, not the other way around!

So running rect1.isSquare() has the expected behavior:

  • The isSquare() function is found
  • rect1 is bound as this, so the function has access to its width and height values
  • The console displays 20, the result of multiplying 4*5.

We’re off to a great start, and it’s easy enough to make more rectangles and extend them with the baseRectangle functionality:

const rect2 = { width: 3, height: 3, __proto__: baseRectangle };

Explicitly setting __proto__ feels pretty gross though. Also, we run into some weird behavior if we mistakenly try to interact with baseRectangle directly. As an example, it has neither a width nor height. Despite that, calling baseRectangle.isSquare() returns true. What the heck?

Since baseRectangle has neither width nor height, when isSquare() is invoked directly on it this.width and this.height are both undefined. In JavaScript, undefined === undefined. Yikes!

So this approach leaves us with 2 gaping flaws - explicitly setting __proto__ is gross and using the prototype object directly has undesirable behavior. Fortunately, there’s an easy way to make this better.

Encapsulate prototype chain modifications during object construction

The most common pattern for ensuring new object instances are created consistently is to encapsulate it into a function. This type of function is called a “constructor” function. We can make a “constructor” function that creates an object, sets __proto__ on it, and then returns the object. Consumers of this constructor function can pass in the parameters, and the function can handle the icky JS internals for them. Something like this should do the trick:

function makeRectangle(width, height) {
  return { width, height, __proto__: baseRectangle };
//                        ^^^^^^^^^^^^^^^^^^^^^^^^
}

const aSquare = makeRectangle(4,4);
aSquare.isSquare(); // true

That’s easy enough! Unfortunately the baseRectangle object still needs to be defined somewhere and it could be mistakenly used. Also, this whole __proto__ syntax is pretty gross and feels like a leaky abstraction. This code is hard to understand if you haven’t explicitly learned about the prototype chain and the magic __proto__ member. I’d been using JS for several years before I learned about it!

How can we make this better? Fortunately, OG JavaScript has an answer!

The new operator

This pattern - using a constructor to setup an object’s prototype chain - is the original design intent. The above example gets some of the details wrong, though:

  • Implementing a prototype object that is floating around
  • Implementing a separate constructor function
  • Explicitly setting the __proto__ property

JavaScript has an alternate syntax for setting up a prototype chain, and it hinges on another unique feature of the language. As it turns out, functions in JavaScript are objects. Don’t believe me? Try this out:

console.log( makeRectangle instanceof Object );
// true

Practically, this means functions can have properties just like any other object. Functions also have a __proto__ property by default - they get the function prototype object. If you’ve ever noticed that functions have methods like apply and bind on them: this is why.

The function prototype object in turn has its __proto__ set to the object prototype. So the prototype chain for a function (which is an object) has both the function prototype and the object prototype. Looking at the properties of a function, we can values from both. These include apply from the function prototype and toString from the object prototype.

function makeRectangle(width: any, height: any): {
    width: any;
    height: any;
    __proto__: {
        isSquare: () => boolean;
        area: () => number;
    };
}
makeRectangle
.
  • apply
  • arguments
  • bind
  • call
  • caller
  • length
  • name
  • prototype
  • toString
CallableFunction.apply<T, R>(this: (this: T) => R, thisArg: T): R (+1 overload)
Calls the function with the specified object as the this value and the elements of specified array as the arguments.
@paramthisArg The object to be used as the this object.
apply

“Okay Allan, how is the fact that functions are objects relevant to constructor functions?” Great question! The short answer is that if we want to implement a class, we don’t need to define the class interface (prototype object) and the class constructor function as separate entities.

Instead, the prototype object should be a property of the constructor function object. for example:

function constructRectangle(width, height) {
	return { width, height, __proto__: constructRectangle.prototype }
}
constructRectangle.prototype = {
	isSquare: function() { return this.width === this.height },
	area: function() { return this.width * this.height }
};

const rect3 = constructRectangle(1,2);
rect3.isSquare(); // false

This solves the problem of the floating rectangle prototype; it’s now firmly anchored to the constructor function where it belongs. It still has some shortcomings though:

  1. Explicitly setting the __proto__ on the rectangle instance it returns
  2. In fact, everything about __proto__: constructRectangle.prototype is icky.
  3. I still have to explicitly create and return the new object I’m constructing.

JavaScript has a special solution for this: the new operator. It gets placed before a function call, ie new rectangle(...) and it augments the function call with some special behavior:

  1. it creates a new (heh) object
  2. It sets the new object’s __proto__ property to the function’s prototype property.
  3. it bind this to the new object in the function call, equivalent to doing rectangle.apply(newObj, ...)
  4. The expression evaluates to the new object after the function call returns

That’s a lot to take in! It might be easier to understand with an example of the new (heh) version of my rectangle constructor function and its usage:

function Rectangle(width, height) {
	// when called with `new`:
	// 1. `this` is set to a new object.
	// Just set the properties directly!
	this.width = width;
	this.height = height;

	// 2. the __proto__ is set automatically to `Rectangle.prototype`
	// No need to set it explicitly here!
	

	// 3. The `new` expression resolves as `this` new object
	// no need to explicitly return it.
}
Rectangle.prototype = {
	isSquare: function() { return this.width === this.height },
	area:     function() { return this.width  *  this.height }
};

// usage:
const rect4 = Rectangle(2,4)
rect4; // ...undefined?

Why is rect4 undefined? Because Rectangle was called without using new. Yikes! Fortunately, it’s an easy fix.

I presented this talk at the SeattleJS August 2024 meetup, where a couple people had a great question: what is the this object that Rectangle() sets a width and height on when it is called without new?

The answer, as usual, is “it depends.” Since this isn’t bound for the function call, it will use the global this in the context of the call. In a browser this is the window object. In node at the top level, this is the globalThis object. In an ES module, this is undefined.

The easy fix is to prefix the Rectangle() call with new:

const rect5 = new Rectangle(2,4);
rect5.isSquare(); // false

Whew, that works! Overall I think this code looks a lot cleaner than before. The Rectangle class interface is neatly encapsulated as Rectangle.prototype. Once you’re familiar with the new operator this code is easy to read.

Once you’re familiar with the new operator this code is easy to read.

Hold up! That’s not great. In fact, it might be terrible. If you call it without new, this version of the Rectangle constructor just touches itself and doesn’t produce anything.

Worse than that, anyone unfortunate enough to call a constructor without new won’t even be given an error. Just the mysterious nothingness that is undefined, quietly slipped into your variable under the table in hopes that nobody notices. Spectacular! Why would anyone use this stupid language?

Browsers! Right, of course. So how do we fix this?

Ensuring constructors are called correctly

There are a few approaches. One is a simple convention - constructor functions start with a capital letter (like Rectangle) and everything else starts with a lower case. The convention of capitalizing class names is common in other OOP languages too. If you didn’t know, now ya know!

Helpful, but we’ve still got a weird IYKYK situation going on here. If you don’t know you’re supposed to use new with capitalized functions, you’re going to get an undefined and that’s not helpful. It would be great if inside the constructor function we could check if new was used. That would work to verify this call was made correctly.

Speaking of this, this is set if new was used. I’ll just check that inside my constructor function!

function Rectangle(width, height) {
	if(this.__proto__ !== Rectangle.prototype) {
		// call this function correctly
		return new Rectangle(width, height);
	}
	this.width = width;
	this.height = height;
}
Rectangle.prototype = {
	isSquare: function() { return this.width === this.height },
	area:     function() { return this.width  *  this.height }
}

// usage
const newRect = new Rectangle(1, 2);
const alsoNewRect = Rectangle(3.1415, 3.1415);

Now the only way to screw up this function call is by not passing it a width and height. That feels like a reasonable expectation to have of a Rectangle consumer.

It comes at a cost, though: that nasty __proto__ JS implementation detail crept back in. Gross! If only there were a better way to check what this is


As I’m sure you can guess at this point, there is! It’s been around since way back in the days of ECMAScript 1, the first standardized JS release from 1997. We’ve got a whole ‘nother keyword just for this! You’ve probably used it whenever you wanted to know if an object was an instanceof a class. Now you know what it’s doing under the hood!

function Rectangle(width, height) {
	if( !(this instanceof Rectangle) ) {
		return new Rectangle(width, height);
	}
	// ...
}

There are a couple things I love about where we ended up here:

  1. The intention of this code is extremely clear. It’s easy to understand even if you don’t know the behavior of the new operator or the JS prototype chain implementation details.
  2. If you are making a constructor, this pattern at the top of the function makes it recognizable as one. IYKYK

This style of if statement, which checks that a necessary condition is met and if not the function returns early, is called a guard clause. They’re a great technique for keeping code readable.

Factory functions

Another approach is to avoid the prototype chain, constructor functions, and new entirely. Instead, the factory pattern can be used. If you’re not familiar, the factory pattern uses a “factory function” to produce objects of some standardized format, very similar to a constructor function. Here’s a factory example:

function makeRectangle(width, height) {
	return {
		width,
		height,
		isSquare: function() { return width === height; },
		area: function() { return width * height; }
	}
}

This looks a lot like an earlier version of the constructor function. It has a few downsides compared to using a class constructor though:

  1. The factory creates a new copy of the isSquare and area functions for each object instance it creates. That’s because the functions need to create a “closure” which captures a reference to the width and height values for the instance - every object has its own copy and each copy uses memory. Objects created using a constructor share one prototype object instance, reducing memory usage.
  2. The instanceof operator can be used anywhere. I can check if any object was made using a specific constructor with obj instanceof Rectangle. As we learned, it works by checking the __proto__ of an object. Factory functions don’t set __proto__, so you can’t check if an object was produced by a specific factory function with instanceof.

Generally speaking, if a constructor function can do what you need, it’s a better choice than a factory function. There are cases where a factory function is better, but that is a topic for another article


So now we have a good understanding of how to create a bunch of objects of the same “class” in JS. What’s next?

Getting classy

Since the first JavaScript standard, ECMAScript 1, this was the best way to write classy code in JavaScript.

🕐 🕝 đŸ•€ 🕛 20 years later

Then along came ECMAScript 6 (ES2015), and with it the introduction of official support for classes and constructors in JavaScript. Here’s what a rewrite using the new syntax looks like:

class Rectangle {
	// It's explicitly called a constructor now
	// This makes it much more discoverable for a new dev
	constructor( width, height ) {
		this.width = width;
		this.height = height;
	}

	// No longer need to use `__proto__` or `prototype` properties
	isSquare() {
		return this.width === this.height;
	}

	area() {
		return this.width * this.height;
	}
}

// usage
const classyRectangle = new Rectangle(1, 7);
classyRectangle.isSquare(); // false
classyRectangle instanceof Rectangle; // true
const classyRectangle2 = Rectangle(1, 6); // ERROR!

This has lots of amazing improvements. First and foremost, the intent is much more clearly communicated to the reader. This is explicitly a class, not just a function that walks and talks like one. Any programmer new to JS can figure out they should search for class javascript for more information to help them understand what’s going on.

This continues with the explicit requirement of a function named constructor. Very searchable, very explicit. When new Rectangle() is written, the Rectangle class constructor method gets called. No more sketchy functions that touch themselves, now our code is so classy it can’t be missed!

The special behavior of new better hidden now too. It’s not as important to understand it’s behavior when used with a class. Most importantly, trying to construct a Rectangle without new provides an extremely clear and easy to understand error message:

Uncaught TypeError: Class constructor Rectangle cannot be invoked without 'new'

Amazing! As a result, we no longer need to start every constructor with a guard that checks this instanceof Rectangle. Less code to write, less code to read, and easier to understand what’s going on. Fantastic!

Finally, we no longer need to explicitly set up a prototype object. Put the members on the class object, and anything that is set outside the constructor() function is automatically put onto the prototype by the JS runtime. This change means you can now use classes effectively without understanding all the complexity of the prototype chain.

Protecting your privates

Fast-forward a while and we are constructing classy Rectangles all over the place. One of our uses involves packing rectangles together. This was working great until the packed Rectangles started shape-shifting. Somewhere, somehow, the packed rectangles are being mutated. It’s really easy to do:

const shapeshiftingRectangle = new Rectangle(1,2);
// pack away the rectangle...

// and now it can still be changed! yikes
shapeshiftingRectangle.width = 640;
shapeshiftingRectangle.height = 480;

Aaaah! The Rectangles private dimensions are exposed, and can therefore be changed. This exhibitionist behavior is unbefitting of a classy Rectangle.

We need a version of Rectangle that isn’t such an exhibitionist. Its width and height are private, and should be treated as such so nobody else can touch them.

Through JavaScript’s long history, the answer has typically been a closure. Closures work because of how memory is managed in JS. As long as a a variable is referenced somewhere, it will be kept in memory. If we create a function that references a local variable, it forms a closure and keeps the variable in memory. So consider this version of the Rectangle class:

class ImmutableRectangle {
	constructor(width, height) {
		const _width = width;
		const _height = height;

		// Now the values are only exposed as functions
		// Can't change the values now!
		this.width    = function() { return _width;  };
		this.height   = function() { return _height; };
	}	
	isSquare() { 
		return this.width() === this.height(); 
	}
	area() { 
		return this.width()  *  this.height(); 
	}
}
// usage
const immutableRect = new ImmutableRectangle(4, 5);
immutableRect.width; // [Function (anonymous)]
immutableRect.width(); // 4
immutableRect._width; // undefined
immutableRect.isSquare(); // false

Since the _width and _height are const values and only accessible from inside the Rectangle’s constructor function, they are “private” and nobody else can see or touch them. I solved the “see them” problem by adding a couple getter functions, width() and height(), which return their respective values. This lets me protect my Rectangle’s privates, but it comes at a cost.

Similar to a factory function, each constructor call is creating a new copy of the width() and height() functions to form a closure on its _width and _height, using more memory. That’s not a terrible way to make sure Rectangle’s members are private. As it turns out, even with the introduction of classes in ES6 that is the best way to do it. The end.

🕐 🕝 7 years later

After another long wait, ECMAScript 2022 (ES13) came along and introduced proper language-level support for private properties. These classy new classes can finally hide their privates. Unlike TikTok and Instagram, hashtags in JS are for the opposite of discoverability. #private #youcantseeme eh? Check this out:

class Rectangle {
	#width;
	#height;
	constructor( width, height ) {
		this.#width = width;
		this.#height = height;
	}
	isSquare() {
		return this.#width === this.#height;
	}
	area() {
		return this.#width * this.#height;
	}
}
let privateRect = new Rectangle(1,2);
privateRect.area(); // 2
privateRect.#width; // Error!

Now the runtime will not let anyone else touch our Rectangle’s privates!

As of 2022 we’ve finally got language-level support for basic Object-Oriented Programming in JavaScript!

It’s been quite a journey to get here. I hope it was a fun one!

Loading comments...