Auf Twitter (die Älteren unter uns erinnern sich sicher noch) fragte Nikolaus: „Woran, wenn nicht am Prototypen, erkennt Array.isArray() Arrays“ und bekam von mir eine viel zu kurze Antwort. Es folgt in diesem Artikel die Langfassung!

Heutzutage ist Array-Erkennung ganz einfach: die Spezifikationen legen direkt fest, dass Array.isArray() Arrays erkennt! Genau genommen verweist die Definition von Array.isArray() auf die spezifikationsinterne Funktion IsArray(), die mehr oder minder sagt: wenn ich auf ein Array angewendet werde, gebe ich true aus, ansonsten false. Arrays sind in ECMAScript sehr klar von normalen Objekten abgegrenzte Spezial-Objekte (sogenannte „Array Exotic Objects“) und daher eigentlich ohne großen Aufwand zu identifizieren. Das war allerdings nicht immer so einfach.

Was ist eigentlich ein Array?

Arrays sind Allerwelts-Bausteine von fast jedem JavaScript-Programm... was genau macht sie exotisch? Zunächst mal nicht viel: Arrays sind zu 99% von normalen JavaScript-Objekten nicht zu unterscheiden. Wir können beliebige Felder definieren, Funktionen wie Object.keys() mit ihnen verwenden und Operationen wie delete funktionieren auch:

let arr = [];
arr.someField = 23;
console.log(arr.someField); // > 23
arr[0] = 42; // Auch nur ein Objekt-Feld
console.log(Object.hasOwn(arr, "someField")); // > true
console.log(Object.hasOwn(arr, 0)); // > true
delete arr.someField;
console.log(Object.hasOwn(arr, "someField")); // > false

Das einzig offensichtlich Spezielle an Arrays sind eine eigene Literal-Syntax ([] statt {}) und ein Prototyp, der Methoden wie push() und splice() bereitstellt. Wir kommen dem Funktionsumfang von Arrays sehr nahe, wenn wir einfach ein neues Objekt mit Array.prototype als Prototyp anlegen:

let fakeArray = Object.create(Array.prototype);
fakeArray.push(23);
console.log(fakeArray[0]); // > 23
console.log(fakeArray.length); // > 1

Unser Fake-Array hat Inhalt (numerische Objekt-Keys) und Methoden wie push() definieren nicht nur neue Felder, sondern erhöhen auch die length um die Anzahl der neuen Elemente. Ein echtes Array macht aber noch mehr:

let fakeArray = Object.create(Array.prototype);
let realArray = [];

fakeArray.push(23);
realArray.push(23);

console.log(fakeArray[0], realArray[0]); // > 23, 23
console.log(fakeArray.length, realArray.length); // > 1, 1

fakeArray[1] = 42;
realArray[1] = 42;

console.log(fakeArray[1], realArray[1]); // > 42, 42
console.log(fakeArray.length, realArray.length); // > 1, 2

realArray.length = 0;
fakeArray.length = 0;

console.log(fakeArray.length, realArray.length); // > 0, 0
console.log(fakeArray[0], realArray[0]); // > 23, undefined

Beim Fake-Array ändert sich die length bei der Benutzung von Methoden, nicht aber bei direktem setzen von Indizes (z.B. fakeArray[1] = 42). Umgekehrt führt ein setzen der length auf 0 beim echten Array zum Löschen des Inhalts, beim Fake-Array hingegen ändert sich nichts am Inhalt. Wie kann das sein? Mit Sicherheit ist doch length ein Getter/Setter-Paar, das Array-Inhalt zählt oder verändert, richtig?

let realArray = [];

console.log(Object.getOwnPropertyDescriptor(realArray, "length"));
// > { value: 0, ... } - KEIN Getter/Setter-Paar

realArray[5] = true;

console.log(Object.getOwnPropertyDescriptor(realArray, "length"));
// > { value: 6, ... } - Magisches Update für "length"!

Oh. Anscheinend ist length doch eine ganz normale Daten-Property auf Arrays und kein Getter/Setter-Paar auf dem Prototypen. Aber wie funktioniert length denn dann?

Exotic Objects

In der ECMAScript-Spezifikation existiert das Konzept des „Exotic Object“, das bestimmte Sorten von Objekt (z.B. Arrays) von „Ordinary Objects“ abgrenzt. Exotic Objects können in einer beliebigen Reihe von Weisen vom Verhalten der normalen Ordinary Objects und auf diese Weise „magische“ Features wie length auf Arrays umsetzen. Als Ordinary gelten jene Objekte, die eine bestimmte Liste von Algorithmen implementieren (plus ein paar Extras für Funktionen) und „Exotic Objects“ weichen von diesen Standard-Bausteinen ab.

Im Falle von Arrays ist der Algorithmus für das Setzen von Properties abweichend definiert:

  1. Das Setzen der length verändert den Array-Inhalt
  2. Das Setzen von numerischen Feldern (d.h. von Array-Indizies) verändert die length
  3. Alles andere verhält sich wie bei normalen Objekten

Auf einem Array ein Feld wie z.B. arr.x = "Hello" zu definieren hat also den gleichen Effekt, als würden wir das auf einem normalen Objekt tun. Setzen wir jedoch arr[7] = 23, erhält length auf magische Weise ein automatisches Update und setzen wir die length auf einen neuen Wert, verändert sich der Array-Inhalte ebenso automagisch. Allein das Vorhandensein dieser einen Ausnahme für den Property-Set-Algorithmus erhebt Arrays in den exklusiven Club der Exotic Objects!

Die ECMAScript-Spezifikation untergliedert den Club der Exotic Objects anhand der diversen Non-Standard-Verhaltensweisen seiner Mitglieder noch weiter. Auf diese Weise kann die Spezifikation zwischen z.B. Arrays und Strings (die beide spezielle, aber unterschiedliche Operationen mit length und Indizes implementieren) auseinanderhalten. Allerdings passiert diese Unterscheidung allein auf der Ebene der Spezifikationen. Das Ziel der Specs ist, über präzise definierte Algorithmen ein bestimmtes beobachtbares Verhalten der Programmiersprache zu garantieren, doch die Algorithmen selbst sind nicht direkt aus JavaScript heraus beobachtbar. Wie mussten sehr genau hinsehen, um das besondere Verhalten von length überhaupt zu erkennen, und dieses Erkennen allein verrät uns nur, dass irgendwas besonderes los ist - was genau unter der Haube mit Array passiert, erklärt allein die Spezifikations-Lektüre.

Nun wissen wir, dass Arrays tatsächlich etwas besonderes sind: eine Subspezies einer besonderen Spezies von Objekt. Wie können wie diese Spezies jetzt in unserem normalen JS-Code von normalen Objekten unterscheiden?

Die Grenzen von Duck Typing und instanceof

Normalerweise ist Duck Typing das Mittel der Wahl, um in JavaScript den Typ von Objekten (näherungsweise) festzustellen, doch das funktioniert bei Arrays nicht besonders gut. Gerade aufgrund ihrer magischen length-Eigenschaft (anstelle eines Getter-Setter-Paars) sind Arrays von herkömmlichen Objekten kaum zu unterscheiden: ein überzeugendes Fake-Array mit numerischen Keys, Array.prototype und einer length-Eigenschaft ist, wie wir gesehen haben, schnell gebaut.

Eine denkbare Alternative zu Duck Typing ist instanceof, aber auch das hat seine Grenzen: wenn wir von Hacks, zu denen wir in Kürze kommen, erst mal absehen, liefert instanceof nur das richtige Ergebnis, wenn seine beiden Operanden aus dem gleichen Browsing Context (d.h. Fenster bzw. Frame) stammen:

// Array und Array-Constructor aus gleichem Frame
console.log([] instanceof Array); // > true
console.log(someFrame.contentWindow.arr instanceof someFrame.contentWindow.Array); // > true

  
// Array und Array-Constructor aus unterschiedlichen Frames
console.log([] instanceof someFrame.contentWindow.Array); // > false
console.log(someFrame.contentWindow.arr instanceof Array); // > false

Das ist ganz streng genommen nicht verwunderlich - Array und iframe.contentWindow.Array sind nun mal zwei unterschiedliche Objekte, und nur eins von beiden ist die Constructorfunktion von einem Array aus einem gegebenen Browsing Context. Hinzu kommt, dass wir mit @@hasInstance den instanceof-Operator ohnehin zu jedem beliebigen Ergebnis kommen lassen können:

class Yep {
  // Bestimmt das Ergebnis von "x instanceof Yep"
  static [Symbol.hasInstance]() {
    return true;
  }
}

console.log([] instanceof Yep); // > true
console.log({ foo: 42 } instanceof Yep); // > true

Und ja, streng genommen können wir den Array-Constructor so patchen, dass instanceof über Frame-Grenzen hinweg funktioniert:

// Normalerweise ist @@hasInstance auf Arrays nicht definiert...
Object.defineProperty(Array, Symbol.hasInstance, {
  value: (x) => Array.isArray(x)
});

Allerdings müssten wir innerhalb dieses Patches Array.isArray() benutzen, was uns auf der Suche nach einem Weg jenseits von Array.isArray() nicht wirklich weiterbringt. Für sich genommen ist und bleibt instanceof zur Array-Erkennung unbrauchbar und wir brauchen einen anderen, definitiven Weg, Arrays - die, egal aus welchem Frame stammend, nun mal Array Exotic Objects sind - zu identifizieren!

Der [[Class]]-Hack

Nachdem sich instanceof als nutzlos erwiesen hat, ist klar, wonach wir suchen: Wir brauchen einen Identifikationsmechanismus, der sich auf aus JS heraus zugängliche Aspekte stützt, die Array Exotic Objects eigen sind - unabhängig vom Browsing Context oder irgendwelchen Symbols auf irgendwelchen Klassen. Aus dieser Erkenntnis entstand in der grauen JavaScript-Vorzeit der folgende Hack:

let isArray = (x) => Object.prototype.toString.call(x) === "[object Array]"; // WTF?
console.log(isArray(42)); // false
console.log(isArray([])); // true

Wie funktioniert das? Im Prinzip per Informations-Leck! Normalerweise hat jede JavaScript-Objekt-Klasse seine eigene Implementierung von toString():

console.log({}.toString());
// > "[object Object]"
// Quelle: Object.prototype.toString()

console.log(function test(x) { return x * x; }.toString());
// > "function test(x) {return x * x;}"
// Quelle: Function.prototype.toString()

console.log([1, 2, 3].toString());
// > "1,2,3"
// Quelle: Array.prototype.toString()

Alle JavaScript-Objektklassen erben von Object.prototype und im Zuge dessen überschreiben sie die Basis-Implementierung von Object.prototype.toString() mit ihren eigenen Stringifizierungs-Algorithmen. Im Falle von Arrays stringifiziert dieser Algorithmus den Array-Inhalt und fügt ihn mit Kommata zusammen. Mittels Object.prototype.toString.call(someArray) umgehen wir aber diesen Array-eigenen Algorithmus und verwenden den Standard-Stringifizierungs-Algorithmus Object.prototype.toString() für unser Array. Und dieser Standard-Stringifizierungs-Algorithmus gibt nicht, wie viele glauben, einfach immer "[object Object]" aus!

Vor ECMAScript 2015 enthielten alle JavaScript-Objekte einen internen String-Wert namens [[Class]]. Die Doppeleckklammer-Notation ist die ECMAScript-Standard-Schreibweise für nicht-öffentliche Felder in Objekten. Ein so beschriebenes Feld ist ein reiner Spezifikationsmechanismus (vergleichbar mit den Non-Standard-Operationen von Exotic Objects) und sollte für Nutzer von JavaScript selbst nicht direkt beobachtbar sein. Soweit die Theorie.

In ES2015 und älter wurde [[Class]] allerdings in Object.prototype.toString zur Stringifizierung von Objekten verwendet! Einfach den Wert von [[Class]] in den String "[object XYZ]" an der Stelle von XYZ einsetzen und fertig! Bei ({}).toString() kam also nur deshalb"[object Object]" heraus, weil [[Class]] in Standard-Objekten eben "Object" war. Für Arrays, deren [[Class]] den Wert "Array" war, müsste also [object Array] herauskommen, doch da Arrays ihre eigene, [[Class]] ignorierende toString()-Implementierung mitbringen, passierte das im Normalfall nicht. Der einzige Weg, den [[Class]]-Wert eines Objekts mit eigener toString()-Implementierung sichtbar zu machen, besteht darin, das toString() von Object.prototype mittels call()-Methode auf die fraglichen Objekte anzuwenden.

Das Endergebnis war ein Hack, der eine löchrige Abstraktion in den ECMAScript-Spezifikationen ausnutzte. Die öffentliche Methode Object.prototype.toString erlaubte den Einblick in einen nichtöffentlichen Aspekt von der Spezifikationsmechaniken, womit wir Objekte genau wie die ES-Specs unterscheiden konnte. Das funktionierte mit Arrays und diversen anderen Standard-Objekt-Sorten recht zuverlässig, doch eine saubere Lösung zur Array-Erkennung sieht natürlich anders aus.

Der Weg zu Array.isArray()

Der Webentwickler-Community einen Mechanismus zur zweifelsfreien Identifikation von Arrays zu geben, war eine recht unkontroverse Idee. Anfangs (bis ES2015) stützte sich Array.isArray() noch auf [[Class]], doch später wurde das Regelwerk vereinfacht: true für Array Exotic Objects, andernfalls false. Das ändert im Endeffekt nicht viel, denn [[Class]] war genau so ein internes Spezifikationsdetail, wie es die Kategorie Array Exotic Object ist, doch am Ende ist es doch der etwas direktere Weg.

In heutigem ECMAScript existiert [[Class]] nicht mehr und Objekte (eingebaute wie auch in JS definierte) können ihre Stringifizierung per @@toStringTag selbst bestimmen. Alles, was von [[Class]] bleibt, sind ein paar zusätzliche Schritte in der heutigen Definition von Object.prototype.toString(), um Abwärtskompatibilität herzustellen. Array.isArray() erkennt seinesgleichen heutzutage ganz einfach per Definitionem und ist daher das am besten unhinterfragte Mittel der Wahl zur Array-Identifizierung. Klar, mit @@hasInstance aus dem Array-Constructor könnte JavaScript heutzutage Arrays auch über Frame-Grenzen per istanceof erkennbar machen, doch das lässt das Gebot der Abwärtskompatibilität natürlich nicht zu. Das wäre viel zu einfach.