Extending built-in classes

Array, Map, Set, Promise — the classes baked into the language — are ordinary classes as far as extends is concerned. You can subclass them, add your own methods, and get an object that behaves like the original plus whatever you piled on top.

That opens the door to a couple of behaviors that surprise people the first time they hit them. Both come down to a single question: when a built-in method needs to make a new object, which constructor does it reach for?

Subclassing Array

Say you want an array of prices that can report its cheapest entry. Extend Array and add the method:

// PriceList is an Array with one extra method (you could add more)
class PriceList extends Array {
  cheapest() {
    return Math.min(...this);
  }
}

let prices = new PriceList(40, 12, 8, 25, 100);
alert(prices.cheapest()); // 8

let bigTickets = prices.filter(item => item >= 25);
alert(bigTickets); // 40, 25, 100
alert(bigTickets.cheapest()); // 25

prices is a real array — indexing, length, push, filter, all of it works — and it also answers cheapest(). Nothing shocking yet.

The interesting line is the last one. prices.filter(...) produced a brand new array, and that new array also has cheapest(). So filter didn’t hand back a plain Array; it handed back a PriceList.

prices
PriceList [40, 12, 8, 25, 100]
.filter(x => x >= 25)
new result = new prices.constructor()
bigTickets
PriceList [40, 25, 100] · has cheapest()
filter builds its result using the array's own constructor, so the result is a PriceList too.

How does filter know to do that? It reads the object’s constructor property. For our array:

prices.constructor === PriceList

When filter, map, slice, and friends need somewhere to put their results, they don’t hardcode Array. They look up this.constructor and build the new collection from that. Since prices.constructor is PriceList, you keep your custom methods all the way down a chain of transformations:

prices
  .filter(x => x >= 25)
  .map(x => x * 2)
  .cheapest(); // still works — every step returns a PriceList

Overriding the result type with Symbol.species

Sometimes the default is exactly what you don’t want. Maybe PriceList is expensive to construct, or its methods only make sense on the original data, and you’d rather have filter and map return plain arrays.

You control this with a static getter keyed by the well-known symbol Symbol.species. If a class defines it, built-in methods call that getter to decide which constructor to use for their results — instead of falling back to this.constructor.

Return Array from it, and the derived arrays become ordinary arrays:

class PriceList extends Array {
  cheapest() {
    return Math.min(...this);
  }

  // built-in methods will use this as the constructor for their results
  static get [Symbol.species]() {
    return Array;
  }
}

let prices = new PriceList(40, 12, 8, 25, 100);
alert(prices.cheapest()); // 8

// filter now creates its result using prices.constructor[Symbol.species]
let bigTickets = prices.filter(item => item >= 25);

// bigTickets is a plain Array, not a PriceList
alert(bigTickets.cheapest()); // Error: bigTickets.cheapest is not a function

prices itself is still a PriceListprices.cheapest() works fine. But filter consulted Symbol.species, got Array back, and built its result as a plain array. The custom method doesn’t ride along anymore.

prices
PriceList · has cheapest()
.filter(…)
constructor = prices.constructor[Symbol.species] → Array
bigTickets
plain Array · no cheapest()
With Symbol.species returning Array, the original stays a PriceList but derived results drop to plain Array.

No static inheritance between built-ins

Built-in classes carry their own static methods — the ones you call on the class itself, not on an instance: Object.keys, Object.defineProperty, Array.isArray, Date.now, and so on.

Here’s a rule you already know from normal classes: when class B extends A, B inherits both kinds of members. Instance methods flow through the prototype chain, and static methods flow too, because B itself gets A as its prototype. That was covered in Static properties and methods.

Built-in classes break that second half. They do inherit instance methods from each other, but they do not inherit static methods.

Consider Array and Date. Both are described in the spec as having Object.prototype at the top of their instance prototype chains — that’s why every array and every date has toString, hasOwnProperty, and the rest of the Object.prototype methods. So far, so normal.

But at the class level there’s no link. Array is not set up so that Array.[[Prototype]] is Object. So there is no Array.keys() static method inherited from Object, and no Date.keys() either. The instance chains connect; the constructor chains don’t.

Here’s the picture for Date and Object:

Object
Object
defineProperty
keys
no [[Prototype]] link
Date
now
parse
Object.prototype
Object.prototype
constructor: Object
toString: function
hasOwnProperty: function
[[Prototype]]
Date.prototype
constructor: Date
toString: function
getDate: function
[[Prototype]]
new Date()
1 Jan 2019
Date.prototype inherits from Object.prototype, but the Date constructor has no [[Prototype]] link to the Object constructor — so Date does not get Object's statics.

Read the two columns separately. On the right, the prototype chain is fully connected: a Date instance points to Date.prototype, which points to Object.prototype. That’s why dates get toString and hasOwnProperty. On the left, the constructor chain is severed — Date has no [[Prototype]] arrow to Object, so Object’s static methods never reach Date.

This is the one place built-in classes diverge from what plain extends gives you. When you write class Date2 extends Object, Date2 would inherit Object’s statics. The native Date deliberately doesn’t.