Patterns und Best Practices in JavaScript: Typüberprüfungen

Tales from the Web side  –  3 Kommentare

In einem meiner letzten Blogbeiträge hatte ich erwähnt, dass Typüberprüfungen in JavaScript nicht ganz ohne sind. Was war noch schnell der Unterschied zwischen typeof und instanceof? Und funktionieren beide Operatoren wirklich so, wie man das selbst auch gedacht hat? Fragen, mit denen sich JavaScript-Entwickler irgendwann beschäftigen sollten. Besser heute als morgen.

JavaScript kennt keine strikte Typisierung, wie es sie beispielsweise in Java gibt, sprich weder bei der Deklaration von Variablen noch innerhalb der Signatur von Funktionen bzw. Objektmethoden wird der Datentyp explizit angegeben. Prinzipiell lässt sich also jeder Variable jeder beliebige Wert (beliebigen Datentyps) zugewiesen und jeder Funktion beliebige Argumente (beliebigen Datentyps) übergeben. So viel dürfte allgemein bekannt sein.

Weniger als gedacht

Hin und wieder möchte man dann aber doch den Typ einer Variablen ermitteln können. Nächstliegende Option ist augenscheinlich zunächst der typeof-Operator, der ja – zumindest wenn man dem Namen Glauben schenken darf – den Typen einer Variablen bestimmt. Was man dabei aber bedenken muss: JavaScript kennt gar nicht so viele verschiedene Typen, insgesamt sieben an der Zahl: Boolean, Number, String, Null, Undefined, Symbol und Object. Das war's dann auch schon.

Da wundert es dann auch nicht, dass der typeof-Operator für Arrays ebenso "object" zurückgibt, wie für Datumsobjekte oder reguläre Ausdrücke. Was aber schon etwas merkwürdiger ist: für den Wert "null" liefert der Operator ebenfalls den Wert "object". Zweite Unregelmäßigkeit: für Funktionen (die in JavaScript ja auch Objekte sind) liefert der Operator den Wert "function".

console.log(typeof {});             // object
console.log(typeof function () {}); // function
console.log(typeof []); // object
console.log(typeof 2); // number
console.log(typeof ''); // string
console.log(typeof true); // boolean
console.log(typeof new Date()); // object
console.log(typeof /tests/i); // object
console.log(typeof null); // object
console.log(typeof undefined); // undefined
console.log(typeof Symbol('B')); // symbol

Zusammenfassend kann man daher sagen, dass sich der typeof-Operator lediglich dann eignet, wenn man generell zwischen primitiven Datentypen, Objekten im Allgemeinen und Funktionen unterscheiden möchte. Er eignet sich dagegen nicht, wenn man wissen möchte, welchen Objekttyp eine Variable hat, sprich wenn man die Klasse bzw. Konstruktorfunktion ermitteln möchte, mit der eine Objektinstanz erzeugt wurde.

Die Sache mit den Instanzen

Um genauer zu unterscheiden, um welchen Typ eines Objekts es sich bei einer Variable handelt, kommt man also mit dem typeof-Operator nicht weiter. Hier verspricht der instanceof-Operator die Lösung.

'use strict';
class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
}
class Employee extends Person {
constructor(firstName, lastName, id) {
super(firstName, lastName);
this.id = id;
}
}
let max = new Person('Max', 'Mustermann');
console.log(max instanceof Person); // true
console.log(max instanceof Employee); // false
let moritz = new Employee('Moritz', 'Mustermann', 4711);
console.log(moritz instanceof Person); // true
console.log(moritz instanceof Employee); // true

Doch was prüft der instanceof-Operator genau? Um das zu verstehen, sollte Folgendes in Erinnerung gerufen werden:

  1. In JavaScript ist in jedem Objekt in der Eigenschaft constructor die (Konstruktor-)Funktion hinterlegt, mit der das Objekt erzeugt wurde.
  2. Die prototype-Eigenschaft einer solchen Konstruktorfunktion wiederum enthält den Prototypen, also das Basisobjekt, auf dessen Basis neue Objektinstanzen erzeugt werden.
  3. Jedes Objekt hat einen Prototypen (außer das Basisobjekt Object und solche Objekte, dessen Prototyp explizit auf null gesetzt wurde). Dadurch ergibt sich die sogenannte Prototypenkette.

Der instanceof-Operator prüft nun, ob der Prototyp, der in der prototype-Eigenschaft der Konstruktorfunktion hinterlegt ist, in der Prototypenkette vorkommt:

console.log(moritz.constructor);           // (1) [Function: Employee]
console.log(moritz.constructor.prototype); // (2) Employee {}
console.log(moritz.__proto__); // (3) Employee {}
console.log(moritz.__proto__.__proto__); // (3) Person {}
console.log(moritz instanceof Person); // true

Kleiner Stolperstein, den man hierbei beachten sollte: der instanceof-Operator funktioniert nicht mit primitiven Datentypen bzw. nur bedingt (nämlich für null und undefined), wie folgendes Listing zeigt:

console.log(4711 instanceof Number);      // false, nicht richtig
console.log(true instanceof Boolean); // false, nicht richtig
console.log("Max" instanceof String); // false, nicht richtig
console.log(null instanceof String); // false, richtig
console.log(undefined instanceof String); // false, richtig

Abgesehen davon stellt sich die Frage: Kann man den instanceof-Operator denn sorglos verwenden, um den Typen einer (Objekt-)Variable zu bestimmen? Die Antwort lautet leider nein, denn neben dem gerade gezeigten kleinen Stolperstein gibt es noch einen ziemlich großen. Und zwar, wenn man den instanceof-Operator im Zusammenhang mit verschiedenen Kontexten einsetzt, sprich innerhalb des Browsers in verschiedenen Frames oder auch – etwas aktueller – bei der Verwendung von Modulen in Node.js.

Achtung bei der Verwendung mit Node.js

Um das Problem zu verstehen, muss man sich erst bewusst machen, wie Node.js bzw. dessen Paketmanager NPM Module (bzw. Pakete) verwaltet. Prinzipiell ist es mit NPM zunächst ja möglich, Module global oder lokal zu installieren.

Ersteres ist vor allem dann sinnvoll, wenn es sich bei den Modulen um solche handelt, die auf dem jeweiligen System von vielen anderen Modulen verwendet werden (klassisches Beispiel wäre hier beispielsweise das Testframework mocha). Letzteres, also die lokale Installation, wird vor allem gemacht, um konkrete Abhängigkeiten eines Moduls lokal in diesem Modul zu bündeln und auf diese Weise Versionskonflikte zu anderen Modulen zu verhindern. Die Module werden dabei standardmäßig im Ordner node_modules gespeichert, wobei Module, von denen das aktuelle Modul abhängig ist, natürlich auch ihrerseits Abhängigkeiten haben können usw.

Insgesamt ergibt sich auf diese Weise also ein Abhängigkeitsbaum, der durch die Verzeichnisstruktur widergespiegelt wird:

example-package       // Hauptmodul
--> node_modules
--> package-a // Abhängigkeit des Hauptmoduls
--> package-a2 // Abhängigkeit des Submoduls package-a
--> package-b // Abhängigkeit des Hauptmoduls
--> package-b2 // Abhängigkeit des Submoduls package-b

Problematisch – zumindest im Hinblick auf den instanceof-Operator – wird das Ganze, wenn ein Modul mehrfach in dieser Baumstruktur auftaucht, wie im folgenden das Modul package-b:

example-package       // Hauptmodul
--> node_modules
--> package-a
--> package-b // package-b als Abhängigkeit von package-a ...
--> package-b // ... und als Abhängigkeit des Hauptmoduls.
--> package-c

Warum das problematisch ist, lässt sich am besten anhand eines einfachen Beispiels nachvollziehen, welches wie folgt aufgebaut ist:

example
--> node_modules
--> module-functions
--> index.js
--> package.json
--> module-persons
--> index.js
--> package.json
--> index.js

Die index.js-Datei für das Module module-persons sieht dabei wie folgt aus:

'use strict';

module.exports = class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
}

Das Modul module-functions greift auf dieses Modul folgendermaßen zu:

'use strict';
const Person = require('module-persons');

module.exports = function isPerson(value) {
console.log('Instanz von Person: ' + value instanceof Person);
}

Zu guter Letzt noch der Inhalt der index.js-Datei des example-Moduls, in der beide der oben genannten Module verwendet werden:

'use strict';
const Person = require('module-persons');
const isPerson = require('module-functions');

let max = new Person('Max', 'Mustermann');
isPerson(max);

Der Aufbau ist also relativ einfach: die Datei index.js (des Moduls example) importiert die Person-Klasse und die isPerson()-Funktion, erzeugt eine Instanz von Person und übergibt diese der Funktion. Innerhalb dieser Funktion wiederum findet dann (siehe oben) die instanceof-Überprüfung statt. Das Modul example ist also sowohl von module-persons als auch von module-functions abhängig, das Modul module-functions wiederum von module-persons.

Lässt man das Programm (über den Befehl node index.js) laufen, erhält man die Ausgabe "Instanz von Person: true", sprich genau das, was man auch erwartet. Anders verhält es sich jedoch, wenn man das Modul module-persons in das node_modules-Verzeichnis unterhalb des Moduls module-functions kopiert (und somit quasi ein npm install simuliert, welches ja auch die Abhängigkeiten in das node_modules-Verzeichnis des jeweiligen Moduls kopiert).

example
--> node_modules
--> module-functions
--> node_modules
--> module-persons
--> index.js
--> package.json
--> module-persons
--> index.js
--> package.json
--> index.js

Startet man das Programm erneut, lautet die Ausgabe des Programms "Instanz von Person: false". Der Grund: die Person-Klasse (und damit die zugrundeliegenden Prototypen), die zur Erzeugung der Instanz max verwendet wurde, ist eine andere, als die, die intern im Modul module-functions (und damit innerhalb der Funktion isPerson()) verwendet wird. Der Prototypenvergleich kann also nur fehlschlagen.

Bedeutet dies nun, dass der instanceof-Operator nicht richtig funktioniert? Nein, das bedeutet es nicht, denn intern werden im Beispiel ja tatsächlich zwei unterschiedliche Objekthierarchien verwendet, die zwar vom Aufbau her gleich sind, aber eben nicht dieselbe Objekthierarchie darstellen. Insofern macht der instanceof-Operator schon das, was er machen soll. Kann man mit dem instanceof-Operator zuverlässig bestimmen, ob ein Objekt Instanz einer Klasse ist? Innerhalb eines Kontextes (bzw. im Falle von Node.js: eines Moduls) schon, sobald man aber die entsprechende Klasse in mehreren Kontexten verwendet, sollte man den instanceof-Operator tunlichst vermeiden.

To be continued ...

Welche Alternativen gibt es nun, um herauszufinden, ob ein Objekt eine Instanz einer Klasse ist, wenn der instanceof-Operator es nicht immer kann? Die Antwort gibt es im nächsten Artikel dieser Reihe.