JS Tip of the Day: Prototypes as Instance of Type

Prototypes as Instance of Type
Level: Advanced

JavaScript uses prototypes for inheritance. Most of the time these prototypes are simply instances of ordinary objects, but there are some unusual exceptions. Some prototypes are also instances of the type for which they provide the inheritance for.

An example is Array.prototype which is, itself, also an array instance. If you’ve ever looked at Array.prototype and its properties, you may have noticed that it has a length property. This is not inherited by other array instances; they are assigned their own length property. The only reason Array.prototype has a length is because because its also an array instance. We can verify this using Array.isArray().

console.log(Array.prototype.length); // 0
console.log(Array.isArray(Array.prototype)); // true

Note that instanceof would not work here because it depends on the prototype chain to see if an object is an instance of something else. Since Array.prototype doesn’t (and can’t) inherit from itself, instanceof will fail if checking to see if its an instance of Array.

console.log(Array.prototype instanceof Array); // false (but it is)

Other than Array, you will also see this with prototypes for Function, Boolean, Number, and String. While there is no isArray() equivalent for these types, we can use the default toString() from Object to identify these as being special types. For any other, ordinary prototype (not implementing a Symbol.toStringTag), this would return "[object Object]".

let { toString } = Object.prototype;
console.log(toString.call(Array.prototype)); // "[object Array]"
console.log(toString.call(Function.prototype)); // "[object Function]"
console.log(toString.call(Boolean.prototype)); // "[object Boolean]"
console.log(toString.call(String.prototype)); // "[object String]"
console.log(toString.call(Number.prototype)); // "[object Number]"

// not special
console.log(toString.call(Date.prototype)); // "[object Object]"
console.log(toString.call(Error.prototype)); // "[object Object]"

This is not a normal, standard characteristic of prototype objects. It exists only for these specific objects and only due to legacy reasons. It can be seen as reflective of how inheritance used to be handled through object instances before Object.create() or class was introduced. For example, historically, to create a constructor that inherited from another, you’d assign the prototype of your constructor to be a new instance of the intended base constructor.

// pre-ES5 inheritance
function Base () {
    this.length = 0;
}
function Derived () {}
Derived.prototype = new Base();

This meant that all methods and instance properties of the base constructor were built-in to the derived constructor’s prototype.

console.log(Derived.prototype.length); // 0 (like with Array.prototype)

And, really, as far as prototypal inheritance goes in general, this makes sense. The prototype should be a fully functioning, template object from which new objects can be created. It’s just that in the context of classes where constructors are involved, instance properties on the prototype that would otherwise be defined in the constructor become redundant. That and the prototype of a class not being an instance with state (notably when using the delegation model) helps protect from changes in that prototype affecting instances, something Array isn’t safe from today.

Array.prototype.push(1, 2, 3);
let empty = [];
console.log(empty[2]); // 3 (oops!)

With the ES2015 specification, when they moved away from the [[Class]] internal slot that was used to identify built-ins, most of the prototypes (all of which up to this point were instances of their type) were converted to ordinary objects. The only exceptions were with Array and Function where explicit callouts were made for compatibility:

NOTE: The Array prototype object is specified to be an Array exotic object to ensure compatibility with ECMAScript code that was created prior to the ECMAScript 2015 specification.

In ES2106 they were also restored for Boolean, Number, and String, again for reasons of compatibility, leaving us with the set of special prototypes we have today.

It’s also worth noting that ES2015’s move away from [[Class]], or more specifically it’s introduction of Symbol.toStringTag, also brought with it some additional compatibility concerns related to our use of Object.prototype.toString() seen above. This was also explicitly called out in the spec:

NOTE: Historically, this function was occasionally used to access the String value of the [[Class]] internal slot that was used in previous editions of this specification as a nominal type tag for various built-in objects. The above definition of toString preserves compatibility for legacy code that uses toString as a test for those specific kinds of built-in objects. It does not provide a reliable type testing mechanism for other kinds of built-in or program defined objects. In addition, programs can use @@toStringTag in ways that will invalidate the reliability of such legacy type tests.

This is what kept toString() working for instances of types like Date and Error, though not for their prototypes given that, as of ES2015, they were no longer also instances of those types. This break in compatibility was not seen as important enough to fix for those types, however, and with newer types like Map, use of Symbol.toStringTag handles this for both the instance and the prototype.

let { toString } = Object.prototype;
console.log(toString.call(new Map)); // "[object Map]"
console.log(toString.call(Map.prototype)); // "[object Map]"
console.log(Map.prototype[Symbol.toStringTag]); // "Map"

More info:


More tips: JavaScript Tips of the Day