Subclassing Arrays in ES2015

Thursday, September 21st, 2017

Prior to ES2015 (ES6), you couldn’t really subclass an array in JavaScript without a few caveats, which kangax outlines in a fantastic post called How ECMAScript 5 still does not allow to subclass array. Now in ES2015, you can. This can be useful for defining custom collection classes that leverage all of the array methods and properties while also automatically implementing the iterable and iterator protocols, which I wrote about recently. In short, objects that implement these protocols can be looped through with the for...of loop or can be used with the spread operator.

Subclassing An Array With ES2015 Classes

Let’s say you want to define a collection class with an average method. You can do that using a class:

class Collection extends Array {
  average(callback) {
    let total = this.reduce((total, item) => {
      return total + callback(item);
    }, 0);

    return total / this.length;
  }
}

You can then instantiate the class, passing in several items just like you would with an array, and see it work like a regular array with a custom average method:

const assert = require('assert');

let studentGrades = new Collection(
  { name: 'Leticia', grade: 95 },
  { name: 'Austen', grade: 85 },
  { name: 'Shane', grade: 90 }
);

assert.ok(studentGrades instanceof Array); // true
assert.ok(studentGrades instanceof Collection); // true
assert.strictEqual(studentGrades.constructor, Collection); // true
assert.equal(studentGrades.length, 3); // true
studentGrades[3] = { name: 'Samantha', grade: 86 };
assert.equal(studentGrades.length, 4); // true
assert.equal(studentGrades.average(student => student.grade), 89); // true
studentGrades.push({ name: 'Leticia', grade: 95 });
assert.equal(studentGrades.length, 5); // true
studentGrades[10] = { name: 'Tom', grade: 75 };
assert.equal(studentGrades.length, 11); // true
studentGrades.length = 4;
assert.deepEqual(studentGrades, [
  { name: 'Leticia', grade: 95 },
  { name: 'Austen', grade: 85 },
  { name: 'Shane', grade: 90 },
  { name: 'Samantha', grade: 86 }
]); // true

Can You Subclass An Array Without A Class?

I then wondered if it was possible to subclass an array without a class. It turns out you can’t, but you can read about several different approaches in How ECMAScript 5 still does not allow to subclass array. However, in that post kangax goes over an approach that does work, which uses something that is now standardized in ES2015, and that is __proto__. Basically this approach takes an array and changes its prototype to another object that inherits from Array.prototype. Here is a slightly modified version of that approach:

function Collection(...args) {
  Object.setPrototypeOf(args, Collection.prototype);
  return args;
}
Collection.prototype = Object.create(Array.prototype);
Collection.prototype.constructor = Collection;
Collection.prototype.average = function(callback) {
  let total = this.reduce((total, item) => {
    return total + callback(item);
  }, 0);

  return total / this.length;
};

Here, the Collection constructor function returns an array instead of the object being constructed, and this array has its prototype changed to another object that inherits from Array.prototype.

This approach leverages Object.setPrototypeOf which was introduced in ES2015 as a way to set the prototype of an object to another object. Prior to this being introduced, the only way to change the prototype of an object was to use the non-standard __proto__ property, so Collection would look like this instead:

function Collection(...args) {
  args.__proto__ = Collection.prototype;
  return args;
}

In ES2015, Object.prototype.__proto__ was standardized, but it is recommended that you use Object.getPrototypeOf / Reflect.getPrototypeOf and Object.setPrototypeOf / Reflect.setPrototypeOf.

And all of the same assertions hold true:

const assert = require('assert');

let studentGrades = new Collection(
  { name: 'Leticia', grade: 95 },
  { name: 'Austen', grade: 85 },
  { name: 'Shane', grade: 90 }
);

assert.ok(studentGrades instanceof Array); // true
assert.ok(studentGrades instanceof Collection); // true
assert.strictEqual(studentGrades.constructor, Collection); // true
assert.equal(studentGrades.length, 3); // true
studentGrades[3] = { name: 'Samantha', grade: 86 };
assert.equal(studentGrades.length, 4); // true
assert.equal(studentGrades.average(student => student.grade), 89); // true
studentGrades.push({ name: 'Leticia', grade: 95 });
assert.equal(studentGrades.length, 5); // true
studentGrades[10] = { name: 'Tom', grade: 75 };
assert.equal(studentGrades.length, 11); // true
studentGrades.length = 4;
assert.deepEqual(studentGrades, [
  { name: 'Leticia', grade: 95 },
  { name: 'Austen', grade: 85 },
  { name: 'Shane', grade: 90 },
  { name: 'Samantha', grade: 86 }
]); // true

Performance Comparison

If you checked out the documentation for Object.setPrototypeOf, you may have noticed the big red warning stating that changing the prototype of an object is a slow operation.

Object.setPrototypeOf performance warning

I ran a JSPerf test comparing these two approaches to subclassing an array, and results varied across browsers.

Performance comparison in Chrome Performance comparison in Safari Performance comparison in Firefox

Support

In terms of support, subclassing an array with a class isn’t something that Babel can polyfill without a few caveats, since __proto__ wasn’t standardized until ES2015, and Object.setPrototypeOf wasn’t added until ES2015. However, browser and Node support is pretty good, which you can see here.

Conclusion

To summarize, you can subclass an array using a class. Without a class, you can subclass an array by creating an array using the Array constructor or literal notation ([]) and changing its prototype to another object that inherits from Array.prototype. Although this approach is faster, I would still recommend using a class for better readability until performance becomes a problem.

Disclaimer: Any viewpoints and opinions expressed in this article are those of David Tang and do not reflect those of my employer or any of my colleagues.

comments powered by Disqus