Classy coding - JavaScript OOP through the ages
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?
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()
:
- The JS runtime checks the
rect1
object, which only haswidth
,height
, and__proto__
properties. - The runtime checks the object at
__proto__
forisSquare
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:
- It checks
rect1
for anisPrototypeOf
property and fails to find it. - It then checks for
rect1.__proto__
which exists, so it checksrect1.__proto__
forisPrototypeOf
. rect1.__proto__
has noisPrototypeOf
method, so it checks ifrect1.__proto__.__proto__
exists. Since it does, it continues searching the prototype chainrect1.__proto__.__proto__
does have anisPrototypeOf
method, so that gets resolved as therect1.isPrototypeOf()
method and is invoked.- Extra credit: The function call checks if
rect1 === baseRectangle.__proto__
and returns false.baseRectangle
is the prototype ofrect1
, not the other way around!
So running rect1.isSquare()
has the expected behavior:
- The
isSquare()
function is found rect1
is bound asthis
, so the function has access to itswidth
andheight
values- The console displays
20
, the result of multiplying4*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!
new
operator
The 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: () => any;
area: () => any;
};
}
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.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:
- Explicitly setting the
__proto__
on the rectangle instance it returns - In fact, everything about
__proto__: constructRectangle.prototype
is icky. - 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:
- it creates a new (heh) object
- It sets the new objectâs
__proto__
property to the functionâsprototype
property. - it bind
this
to the new object in the function call, equivalent to doingrectangle.apply(newObj, ...)
- 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 thatRectangle()
sets awidth
andheight
on when it is called withoutnew
?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 browserthis
is the window object. In node at the top level,this
is theglobalThis
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:
- 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. - 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:
- The factory creates a new copy of the
isSquare
andarea
functions for each object instance it creates. Thatâs because the functions need to create a âclosureâ which captures a reference to thewidth
andheight
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. - The
instanceof
operator can be used anywhere. I can check if any object was made using a specific constructor withobj 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 withinstanceof
.
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 class
y code in JavaScript.
đ đ đ€ đ 20 years later
Then along came ECMAScript 6 (ES2015), and with it the introduction of official support for class
es and constructor
s 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 class
y 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 Rectangle
s all over the place. One of our uses involves packing rectangles together. This was working great until the packed Rectangle
s 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 Rectangle
s private dimensions are exposed, and can therefore be changed. This exhibitionist behavior is unbefitting of a class
y 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!