Composition over Inheritance, with JavaScript examples

If you are into object-oriented programming, you most likely have heard about composition over inheritance. The concept itself is simple: Whenever possible, prefer to compose objects rather than introducing inheritance.

That’s fine and dandy but it’s hard to find good real-world examples. Inheritance is still quite misunderstood and too often misused.

In this post I’ll show you some code, we’ll refactor it using object-oriented design, and end up with a well-designed system step by step.

The Code

In this example I’ll use JavaScript as it’s one of the most common programming languages at the moment and it can easily show my point. Of course, this could be Ruby, Java, C# or any object-oriented language.

Let’s say we are writing an extension for some text editor, such as VSCode or Atom.

When the cursor is under a word, we want to find a function with the same name and go to its definition, if possible:

class GoToDefinition {
  goToDefinition () {
    const word = this.getWordUnderCursor()
    const func = FunctionRepository.find(word)
    if (func) {
      this.open(func.file, func.index)
    }
  }

  open (file, index) {
    // use the editor's API to open the file at the specified index
  }

  getWordUnderCursor () {
    // ... a long method to get the word under the cursor
    // it might use regular expressions and some complex logic
  }
}

So far, so good. We go grab some coffee and call it a day. The next day, we need to implement a very similar action. When we step on a word, we want to open its Help page. For example, if we stand on DoSomething we want to open a browser and navigate to https://some-site.com/help/DoSomething.

An initial approach could be like this:

class OpenHelp {
  openHelp () {
    const word = this.getWordUnderCursor()
    const help = HelpRepository.find(word)
    if (help) {
      this.open(help)
    }
  }

  open (help) {
    // open the browser
  }

  getWordUnderCursor () {
    // ... a long method to get the word under the cursor
    // it might use regular expressions and some complex logic
  }
}

That’s great. Now some avid readers might be already thinking: "What’s with that duplication?" — and that’s a good point. These two classes are so similar, it would be ideal to remove that repetition and keep things DRY.

We could use inheritance and move the getWordUnderCursor method to its own superclass to avoid repetition. This also forces us to come up with a common role for both classes. Naming things is hard, but it’s always a good thing as it makes our code clearer. We can call our new superclass something like WordAction for now, as both subclasses are actually actions which operate on words:

class WordAction {
  getWordUnderCursor () {
    // ... a long method to get the word under the cursor
    // it might use regular expressions and some complex logic
  }
}

Now we just need to update GoToDefinition and OpenHelp to extend from WordAction, and remove the repeated method:

class GoToDefinition extends WordAction { /* ... */ }
class OpenHelp extends WordAction { /* ... */ }

Nice! And we got rid of that annoying repetition. I won’t show tests in this little example but we can assume the getWordUnderCursor method is now properly tested in its own spec, rather than hidden in the implementation of GoToDefinition and OpenHelp.

But now, a new requirement comes in. We have to implement Go To Include. It’s somewhat like a WordAction, but it doesn’t operate on a word. Instead, it takes a whole line and scans for an included file using a regex (something like import * from './some-file'). If it can find that file, it opens it in the editor.

An initial approach could be just adding a method getLineUnderCursor to WordAction:

class WordAction {
  getWordUnderCursor () {
    // ...
  }

  getLineUnderCursor () {
    // ...
  }
}

And then implementing our GoToInclude class:

class GoToInclude extends WordAction {
  goToInclude () {
    const line = this.getLineUnderCursor()
    const match = line.match(/some-regex/)
    if (match) {
      this.open(match.groups.file)
    }
  }

  open (file) {
    // check if file exists and use the editor's API
    // to open the file
  }
}

That works, but notice that GoToInclude it’s not technically a Word Action, it’s more of a Line Action.

More importantly, we have now we have broken an important rule in Object Oriented Design: All classes which inherit from another class must use all of its methods. At the moment, we have GoToInclude which uses getLineUnderCursor, and GoToDefinition and OpenHelp which use getWordUnderCursor.

It is now evident that inheritance is out of place here. Luckily, we can easily fix this by using composition instead of inheritance.

If we take another look at WordAction, we’ll notice that it’s actually just finding lines and words in a text editor. So rather than being an action itself, it’s more of a finder. Figuring out roles like that is very important for object-oriented design. Knowing that, we can rename WordAction to Finder, and just use it in our other classes:

class Finder {
  getWordUnderCursor () {
    // ...
  }

  getLineUnderCursor () {
    // ...
  }
}

class GoToDefinition {
  constructor () {
    this.finder = new Finder()
  }

  goToDefinition () {
    const word = this.finder.getWordUnderCursor()
    // ...
  }

  // ...
}

class OpenHelp {
  constructor () {
    this.finder = new Finder()
  }

  openHelp () {
    const word = this.finder.getWordUnderCursor()
    // ...
  }

  // ...
}

class GoToInclude {
  constructor () {
    this.finder = new Finder()
  }

  goToInclude () {
    const line = this.finder.getLineUnderCursor()
    // ...
  }

  // ...
}

Now, that makes more sense! Notice that we didn’t know the way our code would end up from the beginning. It’s impractical and usually impossible to try to write perfect code from the start.

We just get the job done and then refactor until we satisfy object-oriented design. As you might have guessed, refactoring is very important.

The nice thing about this is that, if needed, we can inject our finder in all of our objects, so we can have different implementations for getLineUnderCursor and getWordUnderCursor. If we want to port our plugin from, say, VSCode to Atom, we can implement a new finder and re-use our old code (Open/Closed Principle).

class GoToDefinition {
  constructor (finder) {
    this.finder = finder
  }

  goToDefinition () {
    const word = this.finder.getWordUnderCursor()
    // ...
  }

  // ...
}

const goToDefinition = new GoToDefinition(new VSCodeFinder()) // vscode
const goToDefinition = new GoToDefinition(new AtomFinder())   // atom

This is what OOP means when it says code will be easier to re-use and extend.

Also it makes things easier to test, as we can inject and mock a dummy finder if needed, and test it separately in its own spec. Note that using real objects should be preferred over mocking when possible, but it’s still great to have that flexibility. If it’s hard to test, it’s not well designed.

Resources

To find out more about object-oriented design, I can’t recommend Practical Object-Oriented Design enough. Sandi Metz has a simple way of explaining complex things and she also has lots of YouTube talks you can watch to compliment her book. It is the book which made OOP click for me.

Another great resource, although more of a workbook than a theory book, is Martin Fowler’s famous Refactoring book. The idea with this book is just take a refactor from the book, and apply it in your code. It’s highly practical. With time, you learn about many different refactors and it helps you develop an eye for code smells. It also explains why refactoring is important and how it’s supposed to work, so it’s definitely worth it.

Those two books are rock-solid foundations which can level your programming skills all the way up!

Conclusion

We saw the reasoning behind using inheritance, as well as one way to find out its limitations. There is a place for inheritance, but only when it makes sense in a particular problem.

I showed how composition can be better and why you should choose composition over inheritance whenever possible. Lucky for us, object-oriented design can help us get to better code in a fairly systematic way.

Leave a Reply