Composition in Javascript

Last week offered some fairly esoteric notes on dependency injection. This week, it’s time to put them to use with a quick dive beneath the waters of object composition. Besides being an extremely common use-case for dependency injection techniques, composition is also a useful alternative to traditional inheritance and an extremely valuable tool for decoupling Javascript objects.

Before we begin, let’s write a naive implementation of a sorted list–an object-oriented straw man, if you will, to carry through a few demonstrations.

function SortedList () {
  this._items = [];
}

SortedList.prototype.add = function (newItem) {
  this._items.push(newItem);
  this.sort();
}

SortedList.prototype.sort = function () {
  this._items.sort();
}

No, this wouldn’t make it to production. Humor the example.

First, inheritance.

To start things off, consider how inheritance might be used to provide a SortedList with a custom sort order. We don’t want to modify the base class, but it’s easy to imagine how children extended from SortedList might provide alternative strategies for maintaining list order. All we need is a comparator to use for sorting, and a small adjustment to make the sort method take advantage of it.

ReverseSortedList.prototype.comparator = function (a, b) {
  return b - a;
}

ReverseSortedList.prototype.sort = function () {
  this._items.sort(this.comparator);
}

This still leaves the relatively inefficient add method defined by the parent, but it’s quick addition to write a behavior that will skip sorting after insertion by simply injecting items at the correct position of a sorted list as they’re added. If add were the only way to expand the list, this would eliminate the need for an independent sort method entirely. But let’s assume that a list can initialize in an unordered state, or that other methods can add items, or whatever it takes to suggest that the list may occasionally require an explicit sort.

// inherits from `ReverseSortedList`
LessBadReverseSortedList.prototype.add = function (newItem) {
  var i = 0, item;
  while (item = this._items[i++]) {
    if (this.comparator(item, newItem) > 0) {
      break;
    }
  }
  this._items.splice(i, 1, newItem);
}

In the real world this behavior would probably be defined directly on the parent, and each child would be implicitly expected to define a comparator method for establishing sort order. That’s a fair assumption for a relatively simple behavior, but as behaviors grow and become more complex the decision to couple them to the parent class becomes less obvious.

If you buy that, then you probably see the problem that can arise when behaviors are inherited. It’s fairly self-evident in the inheritance chain:

LessBadReverseSortedList < ReverseSortedList < SortedList < List

The behaviors to reverse the list and inject new elements in sorted order are both useful; unfortunately, inheritance has left them tangled and inaccessible to other children. If we want to use the “less bad” adder, we would either need to add it to the parent (approach with caution as described above) or derive all sorting strategies from a child that implements it.

So, components.

What would really help our situation is a list whose behavior we could compose depending on our immediate needs. If we could separate out the behaviors for adding and sorting into generalized components, we could easily construct lists that incorporated different types of behavior. Re-writing the SortedList, we might arrive at a declaration that looks something like this:

var reverseSortedList = new SortedList({
  sorter: ReverseSorter,
  adder: QuickAdder
});

Extracting the reverse sorting behavior into a Sorter is very straightforward.

function ReverseSorter () {}

ReverseSorter.prototype.sort = function (items) {
  return items.sort(this.comparator);
}

ReverseSorter.prototype.comparator = function (a, b) {
  return b - a;
}

The Adder behavior’s dependency on the Sorter makes it a bit trickier to implement, but only a bit.

function QuickAdder (sorter) {
  this.sorter = sorter;
}

QuickAdder.prototype.add = function (items, newItem) {
  var i = 0, item;
  while (item = items[i++]) {
    if (this.sorter.comparator(item, newItem) > 0) {
      break;
    }
  }
  items.splice(i, 1, newItem);
}

An object-oriented implementation would probably describe the sorter component’s comparator as a part of a Sorter interface, but javascript’s implicit approach leaves consistency as a problem for the developer. If the adder component expects a comparator to be available, well, we better make sure that it is.

Composing the object

The original sorted list may now be described in terms of generic components for adding and sorting. Note that the only purpose of the original prototype methods is to hand off control to the component parts.

function SortedList (components) {
  this._items = [];
  this.sorter = new components.sorter;
  this.adder = new components.adder(this.sorter);
}

SortedList.prototype.add = function (newItem) {
  this.adder.add(this._items, newItem);
}

SortedList.prototype.sort = function () {
  this.sorter.sort(this._items);
}

Instead of a complicated inheritance chain, changing the behavior of a list is now simply a matter of swapping out the pieces. Not too shabby.

The obligatory caution

Composition can be a great boon for flexibility but it comes with the usual admonition against using it without reason. Inheritance, prototypical or otherwise, is a valuable tool that yields very predictable behaviors. Objects composed of external behaviors may be easier to test and modify, but composition can also obscure objects’ responsibilities and lead to code that is harder to understand and maintain.

Inheritance and composition are not exclusive, either. In the SortedList example, basing sorting behaviors on a prototype that included a default implementation of the comparator class would ensure that a comparator was always available.

At the end of the day, object design will vary based on need. Inheritance will often prove sufficient to meet the design objectives, but sometimes another approach might be needed. In these cases, composition shines.

Featured