Inheritance in JavaScript
There are a lot of opinions about JavaScript inheritance. When you study the different perspectives and begin trying them, it’s clear why. In a nutshell, it’s difficult to know how and when to employ inheritance. As with most things, there are no clear cut answers but I hope to answer a few:
- When should you use inheritance?
- Should you use class inheritance or prototype inheritance?
- What’s the difference?
When should you use inheritance?
As soon as you need it! 🤪 Jokes aside, it can be difficult to know when to take advantage of inheritance. Unfortunately, the vast majority of examples that explain inheritance are contrived. Yes, we understand that Dog
is an Animal
that’s different from Cat
but this doesn’t help us with real problems. So, let’s consider a more realistic problem: sharing utility functions.
It’s worth noting that inheritance is simply one way of addressing this problem. When organizing utilities, composition may be a better pattern. But suppose that these utilities only manipulate data specific to your app. Consider a simple class we might find in your friendly neighborhood To-do app:
class Task {
constructor(title) {
this.title = title;
this.isComplete = false;
}
complete(isComplete = true){
this.isComplete = isComplete;
}
}
const myTask = new Task("finish first draft");
Suppose we also need a Project that can hold multiple tasks. Like Task
, it needs a title and completion status:
class Project extends Task {
constructor(title, tasks = []) {
super(title);
this.tasks = tasks;
}
add(task) {
this.tasks.push(task);
}
}
const myProject = new Project("My Article", [myTask]);
Now we can take advantage of our complete()
method from Task
. We’re using inheritance! 🥳 This is a very simple example.
Inheritance is most useful for sharing logic across similar or related data types. Usually you will have data to represent in your app and common methods for manipulating it. Often, these methods will be needed for different types of data and you can use inheritance to share them. This is done with the extends
keyword.
When you extend a class, you are creating a super-sub relationship between classes. Our “super” class is Task
and our “sub” class is Project
. There’s a clear, one-way hierarchy. Sometimes this isn’t ideal and you need a more loosely defined relationship between objects. So what other option do you have? You can use prototypal inheritance!
Should you use class inheritance or prototypal inheritance?
So, before the internet mob starts mobbing, let me clear things up. All inheritance in JavaScript is prototypal inheritance. A lot of ink has already been shed over this fact. Long-story-short: class
is just a shortcut for implementing class inheritance with JavaScript’s prototypal inheritance. Which begs the question: what is prototypal inheritance?
Let’s revisit our Task
and Project
examples, this time using prototypes:
const completion = {
isComplete: false,
complete(isComplete = true) {
this.isComplete = isComplete;
}
}
function Task(title) {
const task = Object.create(completion);
task.title = title;
return task;
}
const myTask = Task("finish first draft");
You’ll notice that we aren’t using class
at all. Yet, we get a similar result as before. This is done by using Object.create
. This function will use the provided object as the prototype for a new object. What is a prototype? It’s the object that is delegated to when you try to use a property that your object doesn’t have. In our example, task
doesn’t have a property complete()
so it delegates to completion
for that function call. In effect, this works the same way as class inheritance.
This same pattern can be used to re-create Project
:
function Project(title, tasks) {
const project = Object.create(Task(title));
project.tasks = tasks;
project.add = function(task) {
this.tasks.push(task);
}
return project;
}
const myProject = Project("My Article", [myTask]);
We’re inheriting from the task
object which sets title
and isComplete
for us. The task
object inherits from completion
which provides the complete()
behavior. The key difference here is that completion
is not bound to either Task
or Project
directly. It can be used in other objects you create that need the same behavior.
You should use prototypal inheritance directly when you need to compose your functionality and data. You should use classes when you need to strictly colocate your data and functionality. Inheritance is possible in both situations, but the source of inheritance is different. In this case, it’s neither the journey nor the destination, but the origin that you should be thinking about.
What’s the difference?
The difference can be subtle to detect. Better use another example. Let’s say we want to add priority to Project
objects:
const priority = {
level: 0,
set(level) {
if (level >= 0 && level <= 3) {
this.level = level;
}
}
}
function Project(title, tasks) {
const project = Object.create(
Object.assign(Task(title), priority)
);
project.tasks = tasks;
project.add = function(task) {
this.tasks.push(task);
}
return project;
}
With the addition of Object.assign
we can compose our prototype with multiple objects. This enables reusablility, better separation of concerns, and doesn’t impose hierarchy on our data model. But there are drawbacks!
When we use Object.assign
, we are merging the properties from priority
into the object returned from Task()
. This can be a problem if we aren’t careful. Consider a slightly different approach:
//...
const project = Object.create(
Object.assign(priority, Task(title))
);
//...
The object returned from Task()
will be merged into priority
which affects all references to priority
. The order of arguments makes all the difference. Inheriting behavior from multiple objects is powerful but can be easily misused.
Sometimes class inheritance is the better way to go. Classes can provide more structure and predictability. Prototypes provide more flexibility. This is because the prototype interface is the building block upon which classes are created.
Conclusion
I wanted to explore inheritance without getting lost in needless details or glossing over important concepts. Thankfully, inheritance in JavaScript has gotten much easier to work with over the years. The addition of class syntax in ES6 is largely responsible. While less common, working directly with object prototypes is an effective inheritance technique. The central tension between the two approaches is flexibility, with classes being less flexible, although somewhat more predictable than object prototypes.