Prototypen sicher erweitern

Üblicherweise gilt es als unsicher, bei der Erstellung von JavaScript-Applikationen Prototypen für eingebaute Typen zu erweitern. Diese Faustregel stammt noch aus der Zeit vor jQuery, als weise JavaScript-Zauberer Bibliotheken wie MooTools (https://mootools.net) geschaffen haben, die eingebaute Prototyp-Methoden erweitert und direkt überschrieben haben. Als zu viele Magier am gleichen Zaubertrank mitkochen wollten, kam es jedoch zu Problemen. Ohne statische Typsysteme wurden diese Konflikte erst sichtbar, wenn sich verärgerte Benutzer zur Laufzeit darüber beschwerten.

Wenn Sie keinen JavaScript-Hintergrund haben, überrascht es Sie vielleicht, dass JavaScript-eigene Methoden zur Laufzeit beliebig verändert werden können (wie [].push, 'abc'.toUpperCase oder Object.assign). Weil JavaScript so dynamisch ist, gibt es Ihnen für jedes eingebaute Objekt Zugriff auf dessen Prototyp – Array. prototype, Function.prototype, Object.prototype etc.

Damals war die Erweiterung dieser Prototypen ziemlich unsicher. Durch statische Typsysteme wie TypeScript sind Erweiterungen inzwischen deutlich sicherer geworden.6

Im folgenden Beispiel wollen wir den Array-Prototyp auf sichere Weise um eine zip-Methode erweitern. Dafür werden zwei Dinge gebraucht: Zuerst muss der Typ des Array-Prototyps in einer .ts-Datei (z.B. zip.ts) erweitert werden. Danach können wir den Prototyp mit unserer neuen zip-Methode versehen:

// TypeScript über .zip informieren

interface Array<T> { image

zip<U>(list: U[]): [T, U][]

}

// .zip implementieren

Array.prototype.zip = function<T, U>(

this: T[], image

list: U[]

): [T, U][] {

return this.map((v, k) =>

tuple(v, list[k]) image

)

}

  1. image Wir beginnen, indem wir TypeScript mitteilen, dass wir Array um zip erweitern wollen. Hierbei verwenden wir das »Interface Merging« (»Deklarationen verschmelzen« auf Seite 94), um das globale Interface Array<T> mit unserer zip-Methode zu erweitern.

    Da unsere Datei keine expliziten Im- oder Exporte enthält (d.h., sie befindet sich im Skriptmodus, wie in »Modulmodus oder Skriptmodus« auf Seite 224 beschrieben), können wir das globale Array-Interface direkt erweitern. Hierfür deklarieren wir ein Interface mit exakt dem gleichen Namen (Array<T>) und überlassen es TypeScript, beide Interfaces miteinander zu kombinieren. Befänden sich unserer Dateien im Modulmodus (z.B. weil wir für unsere zip-Implementierung etwas importieren müssen), hätten wir unsere globale Erweiterung mit einer declare global-Typdeklaration umgeben müssen (siehe »Typdeklarationen« auf Seite 234):

    declare global {

    interface Array<T> {

    zip<U>(list: U[]): [T, U][]

    }

    }

    global ist ein spezieller Namensraum, der alle global definierten Werte enthält (also alles, was Sie in einer Datei im Modulmodus verwenden können, ohne es zuvor per import zu laden, siehe Kapitel 10), mit dem Sie Namen im globalen Geltungsbereich aus einer Datei im Modulmodus erweitern können.

  2. image Dann implementieren wir die zip-Methode am Prototyp von Array. Hierfür verwenden wir den Typ this, damit TypeScript den Typ von T korrekt aus dem Array ableiten kann, aus dem heraus wir .zip aufrufen.
  3. image Da TypeScript den Rückgabetyp der Mapping-Funktion als (T | U)[] ableitet (TypeScript merkt nicht, dass es sich immer um einen Tupel handelt, bei dem T am Index 0 und U beim Index 1 steht), verwenden wir die tuple-Hilfsfunktion (aus »Typinferenz für Tupel verbessern« auf Seite 142), um einen Tupel-Typ ohne die Verwendung einer Typzusicherung zu erstellen.

Beachten Sie, dass wir durch die Deklaration von interface Array<T> den globalen Namensraum von Array für unser gesamtes TypeScript-Projekt erweitern. Das heißt, selbst wenn Sie zip.ts nicht aus Ihrer Datei importieren, geht TypeScript davon aus, dass [].zip zur Verfügung steht. Um Array.prototype zu erweitern, müssen wir aber sicherstellen, dass jede Datei, die zip verwendet, zuerst zip.ts lädt, um die zip-Methode an Array.prototype zu installieren. Wie können wir sicherstellen, dass zip.ts auf jeden Fall zuerst geladen wird?

Ganz einfach: Wir aktualisieren unsere tsconfig.json, sodass zip.ts explizit von unserem Projekt ausgeschlossen ist. Auf diese Weise müssen Consumer zip.ts auf jeden Fall explizit per import laden:

{

*exclude*: [

"./zip.ts"

]

}

Jetzt können wir zip nach Belieben und vollkommen sicher verwenden:

import './zip'

[1, 2, 3]

.map(n => n * 2) // number[]

.zip(['a', 'b', 'c']) // [number, string][]

Wenn wir das ausführen, wird zuerst map auf das Array angewandt; im zweiten Schritt behandeln wir das Ergebnis mit zip.

[

[2, 'a'],

[4, 'b'],

[6, 'c']

]

Zusammenfassung

In diesem Kapitel haben wir die am weitesten fortgeschrittenen Merkmale des Typsystems von TypeScript behandelt: die verschiedenen Aspekte der Varianz, flussbasierte Typableitung, Verfeinerung, Typerweiterung, Totalität, abgebildete und bedingungsabhängige Typen. Danach haben wir einige fortgeschrittene Muster für die Arbeit mit Typen hergeleitet: Type Branding für die Simulation nominaler Typen, die Nutzung der distributiven Eigenschaft konditionaler Typen für die Arbeit an Typen auf Typebene und die sichere Erweiterung von Prototypen.

Es ist nicht schlimm, wenn Sie nicht alles verstanden haben oder sich nicht alles merken konnten. Lesen Sie dieses Kapitel einfach später noch einmal durch und verwenden Sie es als Referenz, wenn Sie Schwierigkeiten haben, etwas sicherer auszudrücken.

Übungen

  1. 1. Entscheiden Sie für alle unten stehenden Typpaare, ob der erste Typ auf den zweiten zuweisbar ist und warum bzw. warum nicht. Bedenken Sie dabei die Möglichkeiten von Subtypen und Varianz. Wenn Sie sich nicht sicher sind, lesen Sie noch einmal die Regeln am Anfang dieses Kapitels. Wenn das auch nicht hilft, geben Sie die Beispiele einfach in Ihren Codeeditor ein und sehen nach:
  1. a. 1 und number
  2. b. number und 1
  3. c. string und number | string
  4. d. boolean und number
  5. e. number[] und (number | string)[]
  6. f. (number | string)[] und number[]
  7. g. {a: true} und {a: boolean}
  8. h. {a: {b: [string]}} und {a: {b: [number | string]}}
  9. i. (a: number) => string und (b: number) => string
  10. j. (a: number) => string und (a: string) => string
  11. k. (a: number | string) => string und (a: string) => string
  12. l. E.X (definiert im Enum enum E {X = 'X'}) und F.X (definiert im Enum enum F {X = 'X'})
  1. 2. Angenommen, Sie haben ein Objekt des Typs type O = {a: {b: {c: string}}}. Welchen Typ hat dann keyof O? Was ist mit O['a']['b']?
  2. 3. Schreiben Sie einen Exclusive<T, U>-Typ, der berechnet, ob sich die Typen entweder in T oder U oder in beiden befinden. Das Ergebnis von Exclusive<1 | 2 | 3, 2 | 3 | 4> sollte beispielsweise 1 | 4 sein. Notieren Sie Schritt für Schritt, wie der Typechecker Exclusive<1 | 2, 2 | 4> auswertet.
  3. 4. Schreiben Sie das Beispiel (aus »Zusicherungen für definitive Zuweisungen« auf Seite 153) neu, sodass eine definitive Zuweisungs-Zusicherung vermieden wird.