ES6 added quite a lot to JavaScript, and many developers — myself included — were skeptical about the value of some of its biggest features. When the ECMAScript standards were initially published, I thought the addition of classes was moving the language in the wrong direction. I was very happy to go the opposite way, though, and adopt {javascript}Object.create(){/javascript} to build my objects from prototypes. JavaScript has always been an object-oriented language, and contrary to popular claims to the contrary, it didn’t suddenly become object-oriented when it added classes. I just didn’t like that its classes obscure the actual relationships between objects, because JavaScript’s objects have never stopped being implemented with prototypes. But against my expectations, the new class constructs have actually really improved the landscape, and it’s hard to wrap your head around just how much this is true without diving in and fully adopting the new coding patterns (the best way to fully dive in being to read this style guide and then link it to your linter). The point of this article is to try develop an understanding of JavaScript’s class implementation by taking a deep look at the makeup of objects, prototypes, and constructors.
As a start, let’s deconstruct some of the practical pieces of object-oriented programming so that we’ll have a vocabulary to use during our exploration. I’ll to try to express these concepts in a way that’s meaningful for both class-based and prototype-based language patterns. Most problem-solving logic is conceptually built around object use, and before an object can be used, there needs to be an object creation step. The behavior of the objects themselves is informed by method definition and their state initialization, and sometimes multiple kinds of object are structurally connected to one another through inheritance chaining.
At the most trivial level, an object can be directly built to spec like this:
/** Object Creation **/ const topObj = { /** Method Definition **/ doTop: function() { this.top += 1; console.log(`doTop: ${this.top}`); }, /** State Initialization **/ top: 0, }; /** Object Use **/ topObj.doTop(); /// doTop: 1 // Examine the prototype chain console.log(topObj); /// { top: 1, doTop: [Function: doTop] } console.log(Object.getPrototypeOf(topObj)); /// {} console.log(Object.getPrototypeOf(Object.getPrototypeOf(topObj))); /// null
Here {javascript}topObj{/javascript} is just a simple generic object with a prototype of {javascript}Object{/javascript}. It has a data property and a method, but there’s no type. That is to say, it has no shared characteristics with any class of objects. This gets the job done, but it’s kind of a mess. Method definition and state initialization are crammed together at the object creation point, and if another object with these characteristics is needed, it will require the same jumble of initialization and definition at its own creation point.
One way to remove the need to repeat the definition and initialization steps is to define an object constructor and use the {javascript}new{/javascript} operator to create instances of a type. This way, we will have a template that is separate from the the sites where object creation happens.
// Constructor function function Top() { /** Method Definition **/ this.doTop = function doTop() { this.top += 1; console.log(`doTop: ${this.top}`); }; /** State Initialization **/ this.top = 0; } /** Object Creation **/ const topInstance = new Top(); /** Object Use **/ topInstance.doTop(); /// doTop: 1 // Examine the prototype chain console.log(topInstance); /// Top { top: 1, doTop: [Function: doTop] } console.log(Object.getPrototypeOf(topInstance)); /// Top {} console.log(Object.getPrototypeOf(Object.getPrototypeOf(topInstance))); /// {} // Examine the constructor console.log(Top); /// [Function: Top] console.log(Object.getPrototypeOf(Top)); /// [Function] // The circular relationship between constructors and prototypes console.log(Top.prototype); /// Top {} console.log(Top.prototype.constructor); /// [Function: Top]
Here we can see that the type of our instance is {javascript}Top{/javascript}, named for the constructor. A new object is implicitly created when the {javascript}Top{/javascript} function is defined, and it gets set as that function’s {javascript}prototype{/javascript} property. This prototype object is also type {javascript}Top{/javascript}, and its prototype is {javascript}Object{/javascript}. When the {javascript}new{/javascript} operator is used to call a constructor, it sets the new instance’s prototype to the object stored in the constructor’s {javascript}prototype{/javascript} property.
Now defining a method inside a constructor isn’t generally something we’d like to do. The constructor exists to initialize potentially many objects, and in this case, each of those objects will separately contain the entire body of the {javascript}doTop{/javascript} function. We’d rather have all of our objects contain only individual data properties, while any methods are stored in a shared prototype. This is, after all, the whole purpose of prototypes: if a method isn’t found on a particular object, that object’s prototype is then searched for the method in question.
Here, we move method definitions out of the constructor, leaving it to perform only state initialization.
/** State Initialization **/ function Top() { this.top = 0; } /** Method Definition **/ Top.prototype.doTop = function doTop() { this.top += 1; console.log(`doTop: ${this.top}`); }; /** Object Creation **/ const topInstance = new Top(); /** Object Use **/ topInstance.doTop(); /// doTop: 1 console.log(Top.prototype); /// Top { doTop: [Function: doTop] } // Examine the prototype chain console.log(topInstance); /// Top { top: 1 } console.log(Object.getPrototypeOf(topInstance)); /// Top { doTop: [Function: doTop] }
You can see we’ve confirmed that {javascript}Top.prototype{/javascript} is the same as {javascript}Object.getPrototypeOf(topInstance){/javascript}.
JavaScript supports inheritance as well, allowing methods defined in more than one prototype to be called on an object. In order for this to work in practice, we need to ensure two things. First, an instance needs access to all the methods defined in its inheritance tree. Second, all of the pieces of state initialization code need to be run before the instance is used.
The first requirement is satisfied by simply giving a prototype to a prototype object. This chain of prototypes can be as long as necessary. Any method defined on any prototype in the hierarchy will be accessible to the instance at the base of the chain.
The second requirement is satisfied if the body of the constructor called by the {javascript}new{/javascript} operator contains a call to the constructor associated with the next prototype up the chain. It takes a little bit of wrangling to find a reference to that next constructor and bind it to the {javascript}this{/javascript} object being initialized.
/** State Initialization **/ function Bottom() { // Walk up the prototype chain. We get the prototype of Bottom's prototype object // (initially the base Object, but we'll set it to Top's prototype object below), // and bind its constructor to the 'this' object that is initialized by the new // operator. const callSuper = Object.getPrototypeOf(Bottom.prototype).constructor.bind(this); // Call the above constructor with 'this' from the current context callSuper(); this.bottom = 10; } /** Method definition **/ Bottom.prototype.doBottom = function doBottom() { this.bottom += 1; console.log(`doBottom: ${this.bottom}`); }; /** Inheritance Chaining **/ Object.setPrototypeOf(Bottom.prototype, Top.prototype); // The Bottom() constructor calls the Top() constructor, initializing both // the top and bottom properties of the resulting object /** Object Creation **/ const botInstance = new Bottom(); /** Object Use **/ botInstance.doBottom(); /// doBottom: 11 botInstance.doTop(); /// doTop: 1 // Examine the prototype chain console.log(botInstance) /// Bottom { top: 1, bottom: 11 } console.log(Object.getPrototypeOf(botInstance)); /// Bottom { doBottom: [Function: doBottom] } console.log(Object.getProtoTypeOf(Object.getPrototypeOf(botInstance))); /// Top { doTop: [Function: doTop] // Walking up the prototype chain gives us the prototype's constructor console.log(Object.getPrototypeOf(Bottom.prototype).constructor); /// [Function: Top]
Structurally, I really like the result here. The {javascript}botInstance{/javascript} object is type {javascript}Bottom{/javascript} and contains the two data properties; its prototype is also type {javascript}Bottom{/javascript} and contains the {javascript}doBottom{/javascript} method; that prototype’s prototype is type {javascript}Top{/javascript} and contains the {javascript}doTop{/javascript} method. The constructor body is pretty cumbersome, though, and engineering a way for inherited constructors to be called feels hack-ish.
Even so, something like this has probably been the most popular pattern for implementing inheritance for JavaScript objects. The biggest issue that I have with the pattern is the fact that the prototype objects where the methods are defined, including inherited methods, are only ever referred to indirectly. They’re implicitly created by the engine and then edited after the fact to add methods. For multiple methods, there’s nothing really to group those assignment operations together in the same way that all object initialization is grouped into a constructor function. This makes the code less self-documenting than it could be. The same can be said for the call that sets the prototype to implement inheritance. The syntax just isn’t designed to make these structural and behavioral relationships particularly clear.
Setting aside constructor functions for a moment, the other side of the JavaScript design spectrum leans more explicitly on prototype objects. We define a prototype object, including all of its methods, and then use {javascript}Object.create(){/javascript} to construct objects using it.
/** Method Definition **/ const topProto = { doTop: function() { this.top += 1; console.log(`doTop: ${this.top}`); }, }; /** Object Creation **/ const topObj = Object.create(topProto); /** State Initialization **/ topObj.top = 0; /** Object Use **/ topObj.doTop(); /// doTop: 1 // Examine the prototype chain console.log(topObj); /// { top: 1 } console.log(Object.getPrototypeOf(topObj)); /// { doTop: [Function: doTop] } console.log(Object.getPrototypeOf(Object.getPrototypeOf(topObj))); // {}
For a long time, I considered the above code to be the preferred way to define and initialize JavaScript objects. We’re working with a prototype-based language, and here we leverage its syntax to clearly describe how prototypes are used to build our solutions. There are some obvious drawbacks to this approach, however. For one thing, the code is poorly organized. While the method definitions are all nicely contained in the prototype, state initialization spills out into the region where the object is created and used. Also, we can see in the {javascript}console.log(){/javascript} output that there’s no built-in knowledge of an object’s type, making debugging and maintenance a bit harder.
If we’re determined to keep the explicitly-defined prototype for method definitions, we can solve the first drawback by marrying it to a constructor function for object initialization:
/** Method Definition **/ const topProto = { doTop: function() { this.top += 1; console.log(`doTop: ${this.top}`); }, }; /** State Initialization **/ function Top() { this.top = 0; } Top.prototype = topProto; /** Object Creation **/ const topInstance = new Top(); /** Object Use **/ topInstance.doTop(); // doTop: 1 // Examine the prototype chain // The console no longer knows that topInstance is type Top console.log(topInstance); /// { top: 1 } console.log(Object.getPrototypeOf(topInstance)); /// { doTop: [Function: doTop] }
This is closer to what we want. Explicitly setting the {javascript}prototype{/javascript} property of a constructor function is not recommended, though, because there is metadata silently written by the JavaScript engine when a function is defined. For example, in order for the console to understand that {javascript}topInstance{/javascript} is type {javascript}Top{/javascript}, its prototype’s {javascript}constructor{/javascript} property needs to be set to the {javascript}Top{/javascript} function. When the engine does this, it makes {javascript}constructor{/javascript} non-enumerable so that it won’t show with the object’s other properties. We can do this manually using
{javascript}Object.defineProperty(topProto, ‘constructor’, { value: Top });{/javascript}
but things start to get pretty cumbersome. And while we’re on the topic of cumbersome things, implementing inheritance by chaining prototypes and constructors is still just as awkward.
/** State Initialization **/ function Bottom() { // Walk up the prototype chain. We get the prototype of Bottom's prototype object // (initially the base Object, but we'll set it to topProto below), and bind // its constructor to the 'this' object that is initialized by the new operator. const callSuper = Object.getPrototypeOf(Bottom.prototype).constructor.bind(this); // Call Top() callSuper(); this.bottom = 10; } /** Method Definition **/ const botProto = { doBottom: function() { this.bottom += 1; console.log(`doBottom: ${this.bottom}`); }, }; Object.defineProperty(botProto, 'constructor', { value: Bottom, }); Bottom.prototype = botProto; /** Inheritance Chaining **/ Object.setPrototypeOf(botProto, topProto); // The Bottom() constructor calls the Top() constructor, initializing both // the top and bottom properties of the resulting object /** Object Creation **/ const botInstance = new Bottom(); /** Object Use **/ botInstance.doBottom(); /// doBottom: 11 botInstance.doTop(); /// doTop: 1 console.log(botInstance); /// Bottom { top: 1, bottom: 11 }
Structurally, this is a pretty good result. Our instance is an object that contains only data properties, and its methods are defined in its prototype and its prototype’s prototype. The prototype objects contain only method definitions, and constructors are used solely to initialize data properties. But man is it ever cumbersome! The signal to noise ratio of this code is incredibly low, and there are lots of arcane operations to accomplish conceptually simple things (defining an inheritance relationship, giving the console access to a type name, etc).
So now we’ve neatly arrived at the point. Modern JavaScript has given us an alternative. There’s a clear, simple syntax to express this structure. A class can be used to declare a constructor, some methods, and an inheritance relationship — all within a single block. There’s even a special built-in {javascript}super(){/javascript} function that does exactly what our custom {javascript}callSuper(){/javascript} binding does. And at the sites of object creation and use, the code looks exactly the same as it would if we were using legacy constructor functions.
class Top { /** State Initialization **/ constructor() { this.top = 0; } /** Method Definition **/ doTop() { this.top += 1; console.log(`doTop: ${this.top}`); } } /** Inheritance Chaining **/ class Bottom extends Top { /** State Initialization **/ constructor() { super(); this.bottom = 10; } /** Method Definition **/ doBottom() { this.bottom += 1; console.log(`doBottom: ${this.bottom}`); } } /** Object Creation **/ const botInstance = new Bottom(); /** Object Use **/ botInstance.doBottom(); /// doBottom: 11 botInstance.doTop(); /// doTop: 1 // Examine the prototype chain console.log(botInstance); /// Bottom { top: 1, bottom: 11 } console.log(Object.getPrototypeOf(botInstance)); /// Bottom {} console.log(Object.getPrototypeOf(Object.getPrototypeOf(botInstance))); /// Top {} // Wait, are the methods missing from these prototypes?? // Nope, the engine assigns the methods just as we would expect, but they don't // show above because they've been flagged non-enumerable. console.log(Object.getPrototypeOf(botInstance).doBottom); /// [Function: doBottom] console.log(Object.getPrototypeOf(Object.getPrototypeOf(botInstance)).doTop); /// [Function: doTop]
Using classes in cases like this encourages code organization that mirrors most other modern object-oriented languages. Since the structural definitions for a type are all contained within a class block, we can stow those definitions in a simple module file and {javascript}import{/javascript} (or {javascript}require{/javascript} in node.js) it when the class needs to be referenced. Again, take a serious look at this style guide, and strongly consider using it in your JavaScript development to fully realize the benefits of ES6.