?

Getting Under The Hood To Understand JavaScript Prototypes: Part I

Prototypes are the backbone of the JavaScript language, and for a lot of developers who come from object-oriented frameworks, understanding them can be extremely confusing. In this article, we'll pull the hood up and look at how prototypes work at the most basic level in order to understand their place in JavaScript and how classes wrap up prototype functionality as syntactic sugar.

IB
Ian Barczewski
November 10, 2020 12 minute read

In JavaScript, objects run the world. Though simple on the surface, one of the most confusing and oft-misunderstood concepts is prototypes. Many developers who come from object-oriented backgrounds have a difficult time wrapping their heads around the idea of prototypes and prototypal inheritance, and for good reason! 

They're absolutely nothing alike, and yet ES6 introduced the concepts of classes which throws even more confusion into the mix, given that many object-oriented frameworks are built around creating classes and classical inheritance.

When I have ramped up developers on JavaScript, the best way to get them to be able to think about prototypes has been to pop the hood underneath the creation of objects. Not only do they learn the inner workings of object instantiation and how prototypes can be effectively used, they also come to understand the syntactic sugar that was introduced with the class keyword in ES6. The class keyword can be misleading to a lot of people and cause them to believe that JavaScript uses classical inheritance.

Imagine that we're developing a game where you're sent to a deserted island after buying a vacation package from a talking tanuki, and you're in charge of developing a community full of anthropomorphic animals. We have just started on the part of the project where we're going to create villagers, which means that we're going to need objects.

Let's pop the hood open on objects and see how they're created.

Creating an object literal

When we teach developers about JavaScript, one of the first things we teach them is object instantiation. There are other ways to instantiate a plain object with properties, but we'll stick with the way that most people learn first:

let villager = {};
villager.name = 'Raymond';
villager.species = 'cat';
villager.personality = 'smug';

villager.printDetails = function() {
	console.log(`${this.name} is a ${this.species} with a ${this.personality} personality.`);
};

villager.giveGift = function(gift) {
	console.log(`${this.name} is shocked! What a great ${gift}!`);
};

villager.printDetails();
villager.giveGift('black morning coat');

// Output:
//
// Raymond is a cat with a smug personality.
// Raymond is shocked! What a great black morning coat!

Note: Again, you can shorthand this, but for the sake of this explanation, I wanted to go with the simplest way that most JavaScript developers learn when they first start instantiating objects.

Simple, clean and readable! This villager has a name (Raymond), a species (cat) and a personality (smug). We can print the details of Raymond and even send Raymond a gift. Running both methods at the bottom produces logs in our console to prove the code is working.

However, our game is going to require a lot of villagers — 393, to be exact. Repeating this 393 times is going to break a lot of the basic principles we learn as developers. Not only will we have thousands of lines of code repeating generic methods, but any change in logic will need to be done for 393 objects. We need a way to encapsulate the logic behind creating this villager so that we're not repeating the same code over and over.

Let's turn to functional instantiation for this!

Functional instantiation

function Villager(name, species, personality) {
	const obj = {};
	obj.name = name;
	obj.species = species;
	obj.personality = personality;
	
	obj.printDetails = function() {
		console.log(`${this.name} is a ${this.species} with a ${this.personality} personality.`);
	};
	
	obj.giveGift = function(gift) {
		console.log(`${this.name} is shocked! What a great ${gift}!`);
	};
	
	return obj;
};

const villager = Villager('Raymond', 'cat', 'smug');
villager.printDetails();
villager.giveGift('black morning coat');

// Output:
//
// Raymond is a cat with a smug personality.
// Raymond is shocked! What a great black morning coat!

This works! With functional instantiation, you create a function that creates an object with the needed properties and methods. We then return this object at the end of the function.

In this case, we created a constructor function — a function that builds and returns an object — called Villager that takes a name, species and personality and returns an object with those properties, as well as two methods for printing the Villager details and giving the Villager a gift. We encapsulated all of the prior object instantiation work into something far more maintainable and have proven that this code still works by printing Raymond's details in the console and giving Raymond a gift.

The problem with this lies in the reuse when it comes to scaling out more objects. Each time we create a Villager, the instantiated Villager will be duplicating new copies of these generic methods and properties in memory. As stated before, we're going to need 393 villagers, and our Villager function is only going to get bigger and more complex as we add on more functionality and properties. In the future, our Villager, which could potentially have many more methods, would be recreating those methods every time we need a new Villager. That's extremely costly! We would have 393 copies of every method in memory!

How do we get around this? How can we keep the logic encapsulated and maintainable while creating exactly one instance of each generic method in memory? No worries — we have a tool for that!

Functional-shared instantiation

By pulling the methods out into a separate object, we can share the methods across all instantiations created by our Villager function. This is known as functional-shared instantiation.

const villagerMethods = {
	printDetails: function() {
		console.log(`${this.name} is a ${this.species} with a ${this.personality} personality.`);
	},
	giveGift: function(gift) {
		console.log(`${this.name} is shocked! What a great ${gift}!`);
	}
}

function Villager(name, species, personality) {
	const obj = {};
	obj.name = name;
	obj.species = species;
	obj.personality = personality;
	obj.printDetails = villagerMethods.printDetails;
	obj.giveGift = villagerMethods.giveGift;
	return obj;
};

const villager = Villager('Raymond', 'cat', 'smug');
villager.printDetails();
villager.giveGift('black morning coat');

// Output:
//
// Raymond is a cat with a smug personality.
// Raymond is shocked! What a great black morning coat!

While this works, I wouldn't consider this code to be very clean. Using a shared object for methods feels like a hack to get around our problem. Furthermore, if I use the function to create an object called foo, modify the method object, and then create an object called bar, foo and bar will point to different methods (with potentially different logic). This can get out of hand real fast and exposes itself to all sorts of potential accidents.

Before we move on to how we can solve this issue with another form of instantiation, let's take a look at a static method on Object and one of the most powerful tools in our belt: Object.create().

Object.create()

What if I wanted to create an object that inherited properties from another object? For example, what if I wanted to give cats a name and a flag for whether they're small or large, but wanted to ensure that they all had a cuteness rating of 10?

We can do this by calling Object.create()

const animal = {
	isSmall: false,
	cutenessRating: 10,
	name: 'Leif'
}
const cat = Object.create(animal);
cat.name = 'Marla';
cat.isSmall = true;

console.log(cat);
console.log(cat.cutenessRating);

// Output:
//
// {name: Marla, isSmall: true }
// 10

See anything weird? Logically, it would make sense to think that Object.create is instantiating an object with the properties of the object that is passed into the first argument, but that is not so. We know this because when we logged cat to the console, we only saw the name and isSmall properties.

What Object.create is doing is creating a link between cat and animal — think of it as animal being a parent, and cat being the child. All of the properties exist on the parent, not the child. When we assigned name and isSmall on cat, it created the properties on the cat object (hence, why we see them when we console logged cat). However, when we logged the cutenessRating, it failed to find it on the cat object, and thus it went up to the parent — the animal object — and found it there, logging out 10 to the console.

That link is the basis of prototypes — animal is the prototype of cat. As Dan Abramov put it in one of his JavaScript mental model emails, prototypes aren't a special "thing" in JavaScript — think of them as relationships. An object points to another as its prototype.

Now that we understand that, let's track and use the concepts we've just learned to understand the next tool we're going to put into our belt: prototypal instantiation.

Prototypal instantiation

First, a quick rewrite of some of the code, and then an important explanation as to why we went in this direction:

function Villager(name, species, personality) {
	let obj = Object.create(Villager.prototype);
	obj.name = name;
	obj.species = species;
	obj.personality = personality;
	return obj;
};

Villager.prototype.printDetails = function() {
	console.log(`${this.name} is a ${this.species} with a ${this.personality} personality.`);
};

Villager.prototype.giveGift = function(gift) {
	console.log(`${this.name} is shocked! What a great ${gift}!`);
};

const villager = Villager('Raymond', 'cat', 'smug');
villager.printDetails();
villager.giveGift('black morning coat');

// Output:
// Raymond is a cat with a smug personality.
// Raymond is shocked! What a great black morning coat!

A few things going on here:

  1. Every function has a prototype property. Despite objects having prototypes (in the relationship sense), they do not have the prototype property that functions do.
  2. The prototype property is an object that has a constructor property. This property simply points directly back to the function. We can prove this by logging Villager.prototype.constructor === Villager, which will return true.
  3. In our Villager function, we are creating an object that links to Villager's prototype — an object that has printDetails and giveGift added as methods. We add the name, species and personality properties like before and return that object.
    1. Like the animal/cat example, if we logged the object that was returned from the Villager function, we would see name, species and personality but not printDetails and giveGift, as those are on the parent of the link.
  4. When villager.printDetails() is called, as we saw above with the animal/cat example, it will look for printDetails on the villager object, fail to find it and traverse up the link — the prototype — and look there. The printDetails method on the prototype will then be called.

The object that is created from the Villager function will have an object that directly has the name, species and personality properties on the object, but the methods will be on the object's prototype. So when we call printDetails, it will find it on the object's prototype, not the object itself.

Doing this means that we are now sharing methods across objects using the built-in functionality given to us by JavaScript's prototype tools, as opposed to having the methods be defined as separate functions that we drop into the construction function like we were doing before. By using the prototype chain to create our objects, we are taking advantage of prototypal instantiation!

What if I told you there was a way to simplify this by using a simple keyword?

"Pseudoclassical" instantiation

You've probably seen the new keyword used before. Calling a function with new does a couple of things:

  1. Creates a new object using Object.create and passes in the function's prototype.
  2. The function behind the scenes binds this to the new object.
  3. At the end of the function, return this unless a return in the function body is present.

This is how pseudoclassical instantiation works. All of this is handled behind the scenes for us in order to make our code nice and clean while taking out potential missed steps (how many times have you forgotten to return an object in a function on accident?).

So now, our code can look like this!

function Villager(name, species, personality) {
	this.name = name;
	this.species = species;
	this.personality = personality;
};

Villager.prototype.printDetails = function() {
	console.log(`${this.name} is a ${this.species} with a ${this.personality} personality.`);
};
Villager.prototype.giveGift = function(gift) {
	console.log(`${this.name} is shocked! What a great ${gift}!`);
};

const villager = new Villager('Raymond', 'cat', 'smug');
villager.printDetails();
villager.giveGift('black morning coat');

// Output:
// Raymond is a cat with a smug personality.
// Raymond is shocked! What a great black morning coat!

Up until ES6, this was the way we handled sharing methods — we assigned them directly to the prototype object of a function. Now we have the class keyword which makes this much, much cleaner to read.

The syntactic sugar of ES6 classes

Remember how we needed to use the prototype property and assign it functions in order for methods to be shared across objects? The class keyword gives us an even cleaner version of pseudoclassical instantiation.

class Villager {
	constructor(name, species, personality) {
		this.name = name;
		this.species = species;
		this.personality = personality;
	}
	
	printDetails() {
		console.log(`${this.name} is a ${this.species} with a ${this.personality} personality.`);
	};
	
	giveGift(gift) {
		console.log(`${this.name} is shocked! What a great ${gift}!`);
	};
}

const villager = new Villager('Raymond', 'cat', 'smug');
villager.printDetails();
villager.giveGift('black morning coat');

// Output:
// Raymond is a cat with a smug personality.
// Raymond is shocked! What a great black morning coat!

The class keyword creates our constructor function, uses another constructor function inside of it — called constructor — to assign properties and wraps the assignment of printDetails and giveGift to the prototype in nice, clean, syntactic sugar that simply requires the definition of the methods. Understandably, some developers see the above code for the first time and think of classes in the object-oriented sense. 

However, now that we have started at the basics and worked our way through the different instantiation patterns, you now have an understanding of not only how classes in JavaScript work different than most object-oriented classes, but the relationship of objects with prototypes and the way class uses syntactic sugar in order to take the work off of us.

Now that we have started from the basics and gone through various instantiation patterns, hopefully, you have a better understanding of how prototypes and classes in JavaScript actually work. In the next part of this article series, we will start digging into more advanced concepts, like prototype polluting, how to leverage prototypes for inheritance, static methods and how methods like .toString() can exist on every object.

Make sure to follow our Application Development topic for the latest updates.

Share this

Comments