5.2 Composition and Inheritance

Let’s explore how we can improve our application designs beyond what JavaScript offers purely at the language level. In this section we’ll discuss two different approaches to growing parts of a codebase: inheritance, where we scale vertically by stacking pieces of code on top of each other so that we can leverage existing features while customizing others and adding our own; and composition, where we scale our application horizontally by adding related or unrelated pieces of code at the same level of abstraction while keeping complexity to a minimum.

5.2.1 Inheritance through Classes

Up until ES6 introduced first-class syntax for prototypal inheritance to JavaScript, prototypes weren’t a widely used feature in user-land. Instead, libraries offered helper methods that made inheritance simpler, using prototypal inheritance under the hood, but hiding the implementation details from their consumers. Even though ES6 classes look a lot like classes in other languages, they’re syntactic sugar using prototypes under the hood, making them compatible with older techniques and libraries.

The introduction of a class keyword, paired with the React framework originally hailing classes as the go-to way of declaring stateful components, classes have helped spark some love for a pattern that was previously quite unpopular when it comes to JavaScript. In the case of React, the base Component class offers lightweight state management methods, while leaving the rendering and lifecycle up to the consumer classes extending Component. When necessary, the consumer can also decide to implement methods such as componentDidMount, which allows for event binding after a component tree is mounted; componentDidCatch, which can be used to trap unhandled exceptions that arise during the component lifecycle; among a variety of other soft interface methods. There’s no mention of these optional lifecycle hooks anywhere in the base Component class, which are instead confined to the rendering mechanisms of React. In this sense, we note that the Component class stays focused on state management, while everything else is offered up by the consumer.

Inheritance is also useful when there’s an abstract interface to implement and methods to override, particularly when the objects being represented can be mapped to the real world. In practical terms and in the case of JavaScript, inheritance works great when the prototype being extended offers a good description for the parent prototype: a Car is a Vehicle but a car is not a SteeringWheel: the wheel is just one aspect of the car.

5.2.2 The Perks of Composition: Aspects and Extensions

With inheritance we can add layers of complexity to an object. These layers are meant to be ordered: we start with the least specific foundational bits of the object and build our way up to the most specific aspects of it. When we write code based on inheritance chains, complexity is spread across the different classes, but lies mostly at the foundational layers which offer a terse API while hiding this complexity away. Composition is an alternative to inheritance. Rather than building objects by vertically stacking functionality, composition relies on stringing together orthogonal aspects of functionality. In this sense, orthogonality means that the bits of functionality we compose together complements each other, but doesn’t alter one another’s behavior.

One way to compose functionality is additive: we could write extension functions, which augment existing objects with new functionality. In the following code snippet we have a makeEmitter function which adds flexible event handling functionality to any target object, providing them with an .on method, where we can add event listeners to the target object; and an .emit method, where the consumer can indicate a type of event and any number of parameters to be passed to event listeners.

  1. function makeEmitter(target) {
  2. const listeners = []
  3.  
  4. target.on = (eventType, listener) => {
  5. if (!(eventType in listeners)) {
  6. listeners[eventType] = []
  7. }
  8.  
  9. listeners[eventType].push(listener)
  10. }
  11.  
  12. target.emit = (eventType, ...params) => {
  13. if (!(eventType in listeners)) {
  14. return
  15. }
  16.  
  17. listeners[eventType].forEach(listener => {
  18. listener(...params)
  19. })
  20. }
  21.  
  22. return target
  23. }
  24.  
  25. const person = makeEmitter({
  26. name: 'Artemisa',
  27. age: 27
  28. })
  29.  
  30. person.on('move', (x, y) => {
  31. console.log(`${ person.name } moved to [${ x }, ${ y }].`)
  32. })
  33.  
  34. person.emit('move', 23, 5)
  35. // <- 'Artemisa moved to [23, 5].'

This approach is versatile, helping us add event emission functionality to any object without the need for adding an EventEmitter class somewhere in the prototype chain of the object. This is useful in cases where you don’t own the base class, when the targets aren’t class-based, or when the functionality to be added isn’t meant to be part of every instance of a class: there are persons who emit events and persons that are quiet and don’t need this functionality.

Another way of doing composition, that doesn’t rely on extension functions, is to rely on functional aspects instead, without mutating your target object. In the following snippet we do just that: we have an emitters map where we store target objects and map them to the event listeners they have, an onEvent function that associates event listeners to target objects, and an emitEvent function that fires all event listeners of a given type for a target object, passing the provided parameters. All of this is accomplished in such a way that there’s no need to modify the person object in order to have event handling capabilities associated with the object.

  1. const emitters = new WeakMap()
  2.  
  3. function onEvent(target, eventType, listener) {
  4. if (!emitters.has(target)) {
  5. emitters.set(target, new Map())
  6. }
  7.  
  8. const listeners = emitters.get(target)
  9.  
  10. if (!(eventType in listeners)) {
  11. listeners.set(eventType, [])
  12. }
  13.  
  14. listeners.get(eventType).push(listener)
  15. }
  16.  
  17. function emitEvent(target, eventType, ...params) {
  18. if (!emitters.has(target)) {
  19. return
  20. }
  21.  
  22. const listeners = emitters.get(target)
  23.  
  24. if (!listeners.has(eventType)) {
  25. return
  26. }
  27.  
  28. listeners.get(eventType).forEach(listener => {
  29. listener(...params)
  30. })
  31. }
  32.  
  33. const person = {
  34. name: 'Artemisa',
  35. age: 27
  36. }
  37.  
  38. onEvent(person, 'move', (x, y) => {
  39. console.log(`${ person.name } moved to [${ x }, ${ y }].`)
  40. })
  41.  
  42. emitEvent(person, 'move', 23, 5)
  43. // <- 'Artemisa moved to [23, 5].'

Note how we’re using both WeakMap and Map here. Using a plain Map would prevent garbage collection from cleaning things up when target is only being referenced by Map entries, whereas WeakMap allows garbage collection to take place on the objects that make up its keys. Given we usually want to attach events to objects, we can use WeakMap as a way to avoid creating strong references that might end up causing memory leaks. On the other hand, it’s okay to use a regular Map for the event listeners, given those are associated to an event type string.

Let’s move onto deciding whether to use inheritance, extension functions, or functional composition, where each pattern shines, and when to avoid them.

5.2.3 Choosing between Composition and Inheritance

In the real world, you’ll seldom have to use inheritance except when connecting to specific frameworks you depend on, to apply specific patterns such as extending native JavaScript arrays, or when performance is of the utmost necessity. When it comes to performance as a reason for using prototypes, we should highlight the need to test our assumptions and measure different approaches before jumping all in into a pattern that might not be ideal to work with, for the sake of a performance gain we might not observe.

Decoration and functional composition are friendlier patterns because they aren’t as restrictive. Once you inherit from something, you can’t later choose to inherit from something else, unless you keep adding inheritance layers to your prototype chain. This becomes a problem when several classes inherit from a base class but they then need to branch out while still sharing different portions of functionality. In these cases and many others, using composition is going to let us pick and choose the functionality we need without sacrificing our flexibility.

The functional approach is a bit more cumbersome to implement than simply mutating objects or adding base classes, but it offers the most flexibility. By avoiding changes to the underlying target, we keep objects easy to serialize into JSON, unencumbered by a growing collection of methods, and thus more readily compatible across our codebase.

Furthermore, using base classes makes it a bit hard to reuse the logic at varying insertion points in our prototype chains. Using extension functions, likewise, makes it challenging to add similar methods that support slightly different use cases. Using a functional approach leads to less coupling in this regard, but it could also complicate the underlying implementation of the makeup for objects, making it hard to decypher how their functionality ties in together, tainting our fundamental understanding of how code flows and making debugging sessions longer than need be.

As with most things programming, your codebase benefits from a semblance of consistency. Even if you use all three patterns, — and others — a codebase that uses half a dozen patterns in equal amounts is harder to understand, work with, and build on, than an equivalent codebase that instead uses one pattern for the vast majority of its code while using other patterns in smaller ways when warranted. Picking the right pattern for each situation and striving for consistency might seem at odds with each other, but this is again a balancing act. The trade-off is between consistency in the grand scale of our codebase versus simplicity in the local piece of code we’re working on. The question to ask is then: are we obtaining enough of a simplicity gain that it warrants the sacrifice of some consistency?