Part 2: ES6 classes aren't what you think they are
As the MDN and ECMAScript quotes in the introduction linked above show, the class syntax that ES6 introduced does not bring the type blueprint concept as such into JavaScript, but rather provides a recognizable shortcut for people unused to the language’s prototype system. It is of crucial importance to understand that these “classes” are implemented within said prototype system, and not as a new inheritance mechanism.
Again, don’t take my word for it – let your browser show you. Here are three different ways of constructing an object:
As we can see, the structure of the instantiated objects using the class definition and the constructor function is exactly the same, with a prototype chain providing shared functionality. The third example uses a factory function with direct prototype assignment without the new
keyword and therefore lacks an actual constructor, but the generated object otherwise looks and works the same as the other two.
Should you need further evidence, proceed to run this:
Here the non-type nature of ES6 classes is driven home, clear as day: the underlying type of the constructed objects is simply "object"
, and a class is considered to be a "function"
, just as regular constructor functions (or factory functions, for that matter). In other words, an ES6 class isn’t actually a class like you would find in a statically typed class-based language, it just looks that way from a distance. And this is before even taking ES5 transpilation issues into account, which have risen to bite large actors such as babel and TypeScript in the past.
Another peculiar quirk of JavaScript constructor functions is that if they return an object, whatever has been assigned to this
is discarded entirely and that object takes the place of the constructed instance instead. Since ES6 classes are mostly just constructor functions in disguise and don’t define nominal types, their constructors exhibit the same behavior, which is really weird compared to class-based languages:
Type checking
Further on the type track, it is fairly common to employ branching logic based on type checks in class-based languages, such as in these (completely made up and contrived) examples:
JavaScript also has an instanceof
operator, but it doesn’t work like you might expect it to in a strongly typed language. Since prototypes are object instances and not types, what it actually does is check whether a reference to the object instance referenced by the prototype
property of the right-hand side object of the expression (which must be a function) occurs anywhere in the prototype chain of the left-hand side object. For simple cases, this produces familiar semantics:
However, things can quickly start to get more complicated:
Whoa, how did that happen? It’s actually not that difficult to understand, but it is far from obvious. First we have two completely independent constructors: Foo
and Bar
, so naturally an object constructed from one of them is not considered an instance of the other. Then we set Foo
’s prototype property to that of Bar
, i.e. they now point to the same object instance. After that, when we ask instanceof
to check the prototype chain of the existing Foo
instance, it will no longer find a reference to the original Foo
prototype there, but when checking the chain of the Bar
instance it will find a reference to Foo
’s current prototype instance since that instance is now the same instance as Bar
’s prototype. Also note that this happened without modifying foo
, bar
or Bar
in any way. Didn’t expect that, did you? Most people wouldn’t.
Execution segregation
Another gotcha is passing data between different execution contexts, such as windows or (i)frames. Anything created within one context is unique to that context, including built-in objects and their prototypes. Since prototypes are object instances, this means that the prototype chains of equivalent objects created by the same code in different contexts will also be different, and an instanceof
check between such objects will fail. Consider the following example (no embed, sorry):
When the button in the enclosing page is clicked, the four objects sent will arrive as expected to the doStuff()
function and the branching logic will work just fine. When the button in the iframe is clicked, the four objects sent from that frame to the enclosing page will also arrive perfectly intact, which the console logging demonstrates, but there the type checks will all fail – including those for the built-in Array
and Error
– since the object instances in their prototype chains will be different from the instances compared against, even though they originate from the exact same code. Oops.
Programmatic override
In fact, it gets worse. Later editions of the language allow objects to specify a special method which can override the default prototype-instance logic of instanceof
. Let’s go wild and do something like this:
Or, the last two in Jest land:
Another somewhat contrived example for sure, but if you’re using someone else’s code you can’t be sure that they haven’t done something similar – and if the method has been defined as non-configurable, you can’t patch it yourself either. A typical use case would be formalized duck typing, as the final lines with the extension of foo
demonstrate.
In short, relying on instanceof
for type checks in JavaScript is inherently unsafe and cannot be trusted in that capacity. It is also important to note that this is not a design error or mistake: the operator works precisely as intended, but it may not be as you intended, especially when it comes to class semantics. Why? Because ES6 classes aren’t actually classes and prototypes aren’t types. See a pattern?
Context switching
Another gotcha that has caught many a developer by surprise is the many meanings and highly flexible nature of JavaScript’s this
keyword, which deserves a whole article series on its own. Suffice to say, in a class context many would assume that this
will always refer to the current object instance and by and large this assumption holds in ES6 classes as well, realized using prototype chain delegation.
Event handlers
However, there are situations where it doesn’t hold, some of which you’re virtually guaranteed to encounter sooner or later. One has to do with event handlers, which are ubiquitous in a browser environment:
The first call to the brag()
method here outputs "Our family has 3 parents and 12 children!"
as you would expect. Clicking the button with the id brag-button
will, however, output "Our family has undefined parents and [object HTMLCollection] children!"
instead. Oops!?
The reason is that event handlers are called with the value of this
set to the object which triggered the event, which in this case will be a DOM node instance – and it just so happens that such instances lack a parents
property, but do in fact have a children
property that is itself an instance of HTMLCollection
. Switching out the calling context like this is integral to JavaScript and since Family
isn’t actually a class, there’s nothing preventing its methods from having their this
value modified just as with any other JS function call.
Invocation delegation
In fact, let’s do that explicitly:
In this example, this
within brag()
will be set to the object literal passed to the call()
method, which is itself defined on the Function
prototype that all JavaScript functions delegate to (note: not inherit from). This is implicit duck typing at work: as long as the called function finds what it expects to find in the calling context, the call will go through just fine.
This kind of thing was (and still is) used extensively to handle objects which share certain characteristics with built-in data structures, such as the array-like arguments
object available within functions which contains all the arguments said function was called with, regardless of formal parameters specified in the function definition:
Since arguments
does not have Array.prototype
in its own prototype chain, trying to call arguments.join()
directly would fail with a TypeError
, but it is sufficiently similar to an actual Array
instance that we are able to borrow a method from the Array
prototype and let that operate on it instead.
This technique is also the only truly safe way of checking whether an arbitrary object (which is not null
or undefined
) includes an arbitrary property on its own instance rather than somewhere in its prototype chain, since that chain might not reach the default top-level Object.prototype
:
This sort of thing is possible in Python as well, where duck typing is prevalent:
But in C#, which is strongly typed, it will only work with objects of the same nominal type, even when they share a shape:
It is important to note that the term “method” as such does not exist in JavaScript, ES6 or not: what we have is simply object properties that happen to have function references assigned to them. This means that if you pass such a “method object” (remember, pretty much everything in JS is an object, including functions) to something else, there is nothing tying that function to any particular instance of the class/object where it was defined – or to anything else, really. Therefore, if you try to run one of these as a follow-on to the event handler example:
...you will get a TypeError
saying that this.parents
can’t be accessed since this
is undefined. The reason is simple: by decoupling the function reference from the object instance, the function will just act as any other plain function when called, where (in strict mode) this
is indeed undefined.
Field binding
In TypeScript there is support for instance fields, and it is experimental in JavaScript as well (several browsers support it, but it has not yet been standardized via TC39), so you may also have seen something like the following to rectify this particular problem:
With this setup all the frobnicate()
calls will have the same result, since arrow functions capture the original value of this
– like a lexical Function.prototype.bind()
, if you will. However, it is important to note that the function is now precisely what is mentioned above: an instance field, rather than a method defined by the class. Here’s the console representation of the previously created family
and foo
objects:
As we can see, the brag()
method is defined on the Family
prototype, whereas the frobnicate()
method is defined on the foo
instance and will be recreated in full each time a new Foo
object is constructed (in contrast to the mixin approach described in part 1, where only the method reference is copied to the instance). While this is unlikely to have any practical impact in all but the most extreme performance-sensitive applications, it further illustrates that JavaScript works primarily with object instances and not types.
Also, do note that the “unbound this
" problem is not in any way unique to classes, and that the event handler would be just as broken if it were defined in the same way in, for example, an explicit prototype object. The point is that with the class
syntax the use of this
is both expected and mandated, whereas in other contexts those restrictions do not necessarily apply, and the fact that this
isn’t automatically bound in ES6 classes is likely to come as a surprise to many.
Encapsulation
A central tenet of classical object-orientation is the concept of encapsulation, namely that an object should keep its internal state hidden from the outside world and only expose a limited set of defined access points through which this state can be accessed and/or manipulated.
ES6 classes have no access modifiers, so there is currently no way to declare which members are available to what (although there are pending TC39 proposals to introduce true private fields and methods). Such modifiers do exist in TypeScript, where the compiler will prevent you from violating declared visibility, but there is no equivalent at all in the resulting JavaScript code. All object properties are public, just like in Python, and therefore a convention to prefix properties that are supposed to be private with an underscore exists in both languages, but it is important to note that it is just that: a convention, not a guarantee.
In other words, if you expect to be able to reliably control member visibility in ES6 classes, you’re out of luck at the moment. Using constructor functions or factory functions together with closures, however, it’s a breeze, provided that we’re willing to accept recreated instance methods as per the instance field example in the previous section:
Overloading and polymorphism
This is also often touted as one of the primary advantages of OOP, so how does it relate to JavaScript and ES6 classes? In short, it doesn’t. Consider this example in C#:
Here the overload resolution dispatches the Frobnicate()
call to Foo
's definition when it lacks arguments and to that of Bar
when it has one, since to the compiler they are different methods altogether. However, the final line will not compile at all since there is no method reachable from the interface defined by Foo
that takes an argument. If the signature in Bar
were to be changed so that the quux
parameter becomes optional, the third call would be dispatched to that method instead since it provides a better (nearer) match than the inherited method from Foo
.
Let’s switch to Python, which like JavaScript is dynamically typed:
Under the same scenario, the first two calls work the same, but since Python doesn’t have overloading none of the last two will execute. The reason is that the frobnicate()
method from Bar
has hidden the inherited method from Foo
, so the actual dispatch will always target the former when going through the bar
instance, and this method requires a positional argument. If that parameter were made optional, the third call would work just as with the same modification in the C# case.
And finally, here’s the JavaScript equivalent, using class syntax:
As we can see, all four calls go through in this version, but not necessarily as you would expect. There are two mechanisms at work here. First, any JavaScript function, including methods, can be called with any number of arguments, regardless of what’s actually in the function definition. This makes it very easy to write variadic functions, but also puts the onus on said functions to validate their input, which is not done here (thus the appearance of "undefined"
in the output).
The other is prototype chain delegation, which compared to class hierarchy overload resolution is very simple: if the method isn’t found on the original object, each link in the prototype chain is checked in turn until a match is found, or the end of the chain is reached. Since JS function resolution does not depend on signatures as per above, all calls to bar.frobnicate()
are therefore dispatched to the method defined in Bar
and the parameter in the call in the last line is silently discarded.
Another consequence of this is that it is very easy to write “breaking overrides”, intentionally or not. Consider this (simplified) example:
Since neither the type nor the signature (in case of methods) of the requested member matters for object property resolution in JavaScript, this kind of code is perfectly legal, and there are no guarantees as to the type of the return value of the version of getValue()
that ends up getting called from getBigValue()
in the base class – or indeed that it is callable at all. Both foo
and bar
delegate the getBigValue()
call to Foo.prototype
, but in the latter case the subsequent call to getValue()
is handled by the definition in Bar.prototype
, since that member is reached first when the JS engine travels up the prototype chain from the instance referenced by this
– and we end up with an uncaught exception since number objects do not have a toUpperCase()
method anywhere in their prototype chain. The equivalent code in C# would generate a warning that Bar
’s version of getValue()
hides an inherited member, and the call in getBigValue()
would be resolved to Foo
’s version in both cases because of its signature.
And, of course, there is no way to mark anything – members or classes – abstract either, in order to force subclass implementation. In other words, the “usual” inheritance and overloading rules of classes in strongly-typed languages simply do not apply to members in ES6 class definitions. The list of expected things that “simply do not apply” is starting to get rather long, isn’t it?
Next up
In the third and final part of the series we will look at the consequences of all the things we have seen in the first two parts, and how we as developers should handle them.
Continue to Part 3: Should class be considered harmful in JavaScript?