Andrew Nater

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.