The Secret Lives of Classes: A deep dive into javascript prototypes and
classes
Published 2025-04-05, About 13 minute read.
You can be a succesful dev in javascript land and not think about prototypes
much at all. They are a quaint relic, an oddball basis for the pet scripting
language
written in a week for Netscape.
As I spend time working on architectural and library-based applications in
browser-land I find that prototypes and their advantages are being completely
forgotten. Typescript has nudged the great masses of javascript developers
into the habit of using a class. And unless you've been coding
for more than 8 or 9 years, you may have not known that classes were added
late to the language, and there was... something else that allowed
people to encapsulate data, create multiple similar instances, and provide
structures that could abstract your code.
I think this is a shame. I wonder often what life would be like if we stuck
with prototypes and didn't get bullied into copying Java. Perhaps that alone
would be a good post to write about.
My thesis in this post is that prototypical programming is dead. And it's so
dead that specifications and the way the browser implements classes is making
prototypes not even all the helpful to understand.
Okay, let's talk about prototypes 📎
For the uninitiated, javascript was written with
prototypical inheritance
at its core. Instead of making a special template "cookie cutter" thing called
a class, you would actually just use other objects as a template to re-use
functionality.
It's quite brilliant, if you think about it. Why make a special entity apart
from objects? If you want a whole class of objects to have the same behavior,
point them to a single object called their "prototype." If you try to use a
method on the original object and the object doesn't have that method, it will
look at its prototype to see if that object has it, and if that one
doesn't, it will look at its prototype, and so on, until the method
is found. If it isn't, we get undefined back, because, well, that
thing was never defined in the prototype chain.
Here's an example where we make a Cat "class" using prototypes only.
This is extremely flexible, because you can more easily compose behavior. Want
your objects to do X? Put X on its prototype object. Easy.
This also did something very nice regarding inheritance. You could avoid the
diamond problem
pretty easily by just being smart about mixing and creating your prototype
objects.
Don't want to have to manually create a new object for every instance?
Javascript had you covered. Every function is actually a constructor. If you
attached a prototype to that function, you had yourselve a constructor with a
prototype already attached to any instance you created.
So, normally for the "cat" example above, you probably would have this
instead:
This is where Javascript's distinctive this comes in.
this is someting so uniquely javascript, that also could probably
be its own blog post. For this discussion, it's important to note that
this exists because when you're writing prototypes you need a way
to reference whatever object you're ending up on. And with Javascript, the
context of where functions are called changes all the time.
Why is this flexible? Well, if you want to share functionality, you just "mix
it in."
Typescript enters the chat 📎
There were many that weren't so happy with prototypes alone. This is
understandable, because with the power of prototypical inheritance and mixing
prototypes came great responsibility. It's hard to ensure that methods are on
an object or its prototype chain if they could change at any moment at
runtime.
One way to make this process a bit streamlined is to use classes. The way
they're presented makes them seem like their behavior is set in stone and
reliable. And this is very true in other languages that use class inheritance.
So Typescript creates classes that transpile into javascript.
This is how it started: classes are just "syntactic sugar." You can write
classes, and out comes typescript prototypes.
Here is a class in Typescript...
And here's how that's desugared into ES5...
It's not as simple as "just syntactic sugar" anymore 📎
As more and more features are added to javascript, it's harder and harder to
maintain simple transpilation.
Decorators were added to Typescript in version 1.5 in 2015, and ever since
then you can enable them with the --experimentalDecorators flag,
even though today they're a totally legit typescript feature that's in no way
unstable.
They are still called "experimental" because there are competitors... kind of.
Decorators have also been talked about in various forms in babel transpilation
and natively in TC39 proposals. To support all these variations and new
features being written into future spec proposals very smart people had to
start making adjustments to how things in a class are transpiled.
In Typescript 3.7, the folks at Microsoft wrote an
interesting explanation
of why they were changing how class profields were transpiled.
In a nutshell, this is how Typescript (and many others) thought class fields
would transpile:
When in reality, this is what needs to get transpiled
I actually can't tell you exactly why... but this is not the expected
behavior, at least, not if this were simple desugaring of prototypes. There is
something extra going on to accomodate classes. I think this mostly is a way
to define properties on the class that have an undefined value (the
uninitializedField.)
What's interesting is that today-
this is how fields are initialized natively in the browser when using
classes.
That's right, if you use a field in plain ol' javascript in the browser, it's
actually applied to the constructed instance using defineProperty.
That's really specific 📎
Why does that matter? Well, I ran into this error while working on my
Minne library:
You will see this error in the console
It turns out that browsers interpret and run field initializers using
basically Object.defineProperty under the hood. When I try to run
my method for Minne, I'm also using the same method to set properties with
special functionality. It conflicts!
The solution is to run my Minne method in the constructor only... which is
fine, but not as nice for the consumer. That means some fields are field
definitions on the class, and some might be in the constructor.
This isn't just my problem though. If you look at the Lit documentation,
they run into this same issue
for intializing values on class fields
MDN explains how this is so under the hood, which leads to some unexpected behavior with invoking setters on a base
class.
So here's a TLDR of this class fields thing:
Browsers are making native behavior with javascript classes that are not
intuitively mapped to javascript prototypes. If you were to be writing a
javascript prototype of the examples above, I think you would be
very surprised by the defineProperty native behavior.
I think for better or worse classes are the main entity now for abstractions
and reuse, so the behavior under the hood does not have to be expected or
consistent with prior prototype patterns.
Another example: making a custom element 📎
Have you tried to make a custom element with just Javascript prototypes?
First off, we need to talk about how to subclass with prototypes, because to
make a custom element you are required to extend HTMLElement, and
you must call `super()` in the constructor.
Let's say that you have a Base prototype - Animal, and you want to extend off
of that prototype. Let's make a Cat. First, what it looks like with classes.
Okay, fair enough. Now let's see what you need to do with prototypes.
Conceivably, classes are just shorthand for the following:
Okay, so lots of code. And this prototype thing can be confusing if you're not
used to it. I can see why people wanted to retreat to classes.
Now, let's try to make a custom element in prototype land. You do the exact
same process as above...
We run into an error:
What the heck? HTMLElement can't be called like any other function?
It turns out that you can't. There are some "Functions" or classes like
HTMLElement that you can't call directly. When you try to
subclass with a prototype, and you call HTMLElement.apply(this),
you run into an issue where the constructor function can't be called. If you
try to use new and spin up a new HTMLElement like
the error suggest, you get a different error
So what is a web component connoisseur like us supposed to do if we want to do
it with ES5, pre-class, prototypes? It turns out it's
not possible in strictly ES5.
The need for Reflect.construct 📎
What we need is a way to "extend" our prototype by constructing a new
sub-class instance while maintaining the prototype link of the base class.
This is actually
just not possible pre-es6
What we need is a new feature housed in the Reflect API which was
included in ES6 and finalized in 2015
Reflect.construct Is basically the function that
performs new, and effectively allows us to do
super(). This is perfect for classes, because that's what
extends and constructor() do.
This is how we get around that issue of using HTMLElement as a function. This
is how we "call super" with prototypes. This is how we can extend
HTMLElement for custom elements.
Class internals aren't what they used to be 📎
So what am I trying to say? We're well past the world of prototypes.
If we're well past prototypes and not willing to risk using prototypes for
fear of their dangers, I'm wondering if it's important at all that
people dig into prototype intracacies. We can understand everything in terms
of classes. I've always heard "it's good to learn about to understand what
goes on under the hood" but it's actually way more confusing to look under the
hood, and trying to model classes using prototypes is complicated. Why not
just live in the world of that extra type abstraction of classes?
I asked someone to explain the difference between javascript class inheritance
and java class inheritance, and they couldn't actually say that anything was
different except that javascript has this prototype primitive that
can be changed at run time, and Java class inheritance can't be changed at run
time. Otherwise, for all intents and purposes, the inheritances, the methods,
the class fields, behave nearly identically to other languages.
On a personal note, this makes me a little sad. The one distinct feature of
javascript was this experimental mode of inheritance that allowed exceptional
flexibility at runtime. I don't think we truly found the benefits of this
extra flexibility, and most people just complained about its unfamiliarity
instead of learning to use prototypes correctly. And now it's been eclipsed by
a lumbering abstraction used by all the "big boy" languages.
This matters in a world where javascript is (practically and really) the only
language for web apps
Oh well. There's always Rust. And maybe someday we can compile to web
assembly?
I had to do a lot of research for this post and I probably made some mistakes.
Let me know. Find me on Bluesky or Mastodon. I also have an RSS feed here