TypeScript besitzt ein Typsystem von Weltklasse, das leistungsstarke Programmiertechniken auf Typebene ermöglicht, die selbst den mürrischsten Haskell-Programmierer eifersüchtig machen. Wie Sie inzwischen wissen, ist das Typsystem nicht nur äußerst ausdrucksstark, sondern auch einfach zu benutzen. Die Deklaration von Typbeschränkungen und -beziehungen ist einfach und knapp und wird in den meisten Fällen automatisch abgeleitet.
Dieses ausdrucksstarke und ungewöhnliche Typsystem ist nötig, weil JavaScript so dynamisch ist. Die Modellierung von Prototypen, der dynamischen Bindung von this, dem Überladen von Funktionen und sich ständig ändernde Objekte erfordern ein reichhaltiges Typsystem und eine Werkzeugkiste an Typoperatoren, über die selbst Batman staunen würde.
Ich beginne dieses Kapitel mit einer eingehenden Betrachtung von Subtypen, Zuweisbarkeit (assignability), Varianz und Typerweiterung (type widening) in TypeScript, um Ihr in den letzten Kapiteln entwickeltes Verständnis zu vertiefen. Danach gehe ich detailliert auf die kontrollflussbasierten Typechecking-Möglichkeiten von TypeScript ein. Hierzu gehören Dinge wie Typpräzisierung (refinement) und Vollständigkeitsprüfungen (exhaustiveness checking, auch Totalität). Es geht weiter mit fortgeschrittenen Programmierkonzepten wie dem schlüsselbasierten Zugriff (»keying into«) auf Objekte, dem Umwandeln und dem Iterieren über Objekttypen anhand bedingungsabhängiger Typen. Außerdem sehen wir, wie Sie eigene Typschutz-(type guards-)Mechanismen und »Notausgänge«, Typzusicherungen (assertions) und definitive Zuweisungs-Zusicherungen (definitive assignment assertions) nutzen können. Zum Schluss komme ich zu fortgeschrittenen Mustern, um Ihre Typen noch sicherer zu machen: dem Companion-Objektmuster, Verbesserungen bei der Ableitung von Tupel-Typen, der Simulation namensbasierter (nomineller) Typen und der sicheren Erweiterung des Prototyps.
Wir beginnen mit einem genaueren Blick auf die Typbeziehungen in TypeScript.
In »Wo wir gerade von Typen sprechen ...« auf Seite 18 haben wir bereits über die Zuweisbarkeit von Typen gesprochen. Inzwischen kennen Sie die Eigenarten der meisten TypeScript-Typen, und wir können tiefer in die Materie eintauchen. Wir beginnen mit der Frage: Was ist ein Subtyp?
Subtyp
Angenommen, Sie haben zwei Typen A und B. Dabei ist B ein Subtyp von A. In diesem Fall können Sie B auf sichere Weise überall dort verwenden, wo ein A benötigt wird (Abbildung 6-1).
Abbildung 6-1: B ist ein Subtyp von A.
In Abbildung 3-1 am Anfang von Kapitel 3 können Sie sehen, welche Subtyp-Beziehungen es in TypeScript gibt. Zum Beispiel:
Nach der oben gezeigten Subtyp-Definition bedeutet das:
Wie Sie sich denken können, ist ein Supertyp das Gegenteil eines Subtyps.
Angenommen, Sie haben zwei Typen A und B. Dabei ist B ein Supertyp von A. Dann können Sie überall auf sichere Weise ein A verwenden, wo ein B benötigt wird (Abbildung 6-2).
Abbildung 6-2: B ist ein Supertyp von A.
Bezogen auf das Flussdiagramm in Abbildung 3-1 bedeutet das:
Dies ist einfach das Gegenteil der Funktionsweise von Subtypen, nichts weiter.
Bei den meisten Typen lässt sich leicht ermitteln, ob ein Typ A ein Subtyp eines anderen Typs B ist. Bei einfachen Typen wie number, string etc. können Sie einfach im Flussdiagramm in Abbildung 3-1 nachsehen oder es durch logisches Denken herausbekommen (»number ist Teil der Vereinigungsmenge number | string, es muss also ein Subtyp davon sein«).
Bei parametrisierten (generischen) und anderen komplexen Typen ist das allerdings nicht so einfach. Hier einige Beispiele:
Die Regeln für die Bestimmung von Subtypen für verschachtelte Typen (d.h. Dinge mit Typparametern wie Array<A>, Formen mit Feldern wie {a: number} oder Funktionen wie (a: A) => B) sind deutlich schwerer zu durchschauen, und die Antworten sind nicht immer klar. Tatsächlich unterscheiden sich die Regeln für die Subtypen-Bestimmung in fast allen Programmiersprachen.
Damit die folgenden Regeln einfacher lesbar sind, zeige ich eine Art Kurzschrift, die es uns erleichtert, Typen präziser und knapper zu beschreiben. Diese Schreibweise ist kein gültiges TypeScript. Sie soll nur sicherstellen, dass wir beim Sprechen über Typen eine gemeinsame Sprache sprechen. Und keine Sorge: Das ist keine Mathematik:
Um besser zu verstehen, warum die Regeln zur Bestimmung von Subtypen komplexer Typen bei den Programmiersprachen unterschiedlich sind, sehen wir uns am besten ein Beispiel an: Formen (shapes). Angenommen, eine Form beschreibt einen Benutzer in Ihrer Applikation. Mit ein paar Typen beschrieben, könnte das etwa so aussehen:
// Ein bereits vorhandener Benutzer, den wir vom Server erhalten haben
type ExistingUser = {
id: number
name: string
}
// Ein neuer Benutzer, der noch nicht auf dem Server gespeichert wurde
type NewUser = {
name: string
}
Angenommen, ein Praktikant in Ihrem Unternehmen soll Code schreiben, um einen Benutzer zu löschen. Er beginnt so:
function deleteUser(user: {id?: number, name: string}) {
delete user.id
}
let existingUser: ExistingUser = {
id: 123456,
name: 'Ima User'
}
deleteUser(existingUser)
deleteUser übernimmt ein Objekt des Typs {id?: number, name: string}. Dies wird an existingUser des Typs {id: number, name: string} weitergereicht. Hier ist der Typ der id-Eigenschaft (number) ein Subtyp des erwarteten Typs (number | undefined). Das gesamte Objekt {id: number, name: string} ist also ein Subtyp von {id?: number, name: string}, und TypeScript ist zufrieden.
Sehen Sie, wo hier das Problem liegt? Es ist subtil: Nach der Übergabe eines ExistingUser an deleteUser weiß TypeScript nicht, dass die id des Benutzers gelöscht wurde. Wenn wir versuchen, existingUser.id zu lesen, nachdem die ID per deleteUser(existingUser) gelöscht wurde, meint TypeScript immer noch, dass existingUser.id den Typ number hat!
Die Verwendung eines Objekttyps an Orten, an denen eigentlich der Supertyp erwartet wird, kann zu Problemen führen. Warum ist das in TypeScript trotzdem erlaubt? TypeScript ist nicht dafür entwickelt, vollkommen sicher zu sein. Das Typsystem soll tatsächliche Fehler abfangen, aber dennoch einfach zu benutzen sein. Sie sollen verstehen, warum ein Fehler auftritt, ohne hierfür einen Abschluss in Programmiersprachentheorie zu benötigen. Hier hat die Unsicherheit praktische Gründe: Da destruktive Updates (wie das Löschen einer Eigenschaft) in der Praxis recht selten vorkommen, ist TypeScript hier nicht so streng und erlaubt Ihnen die Zuweisung eines bestimmten Objekts, obwohl eigentlich dessen Supertyp erwartet wird.
Und wie ist das anders herum? Können Sie ein Objekt zuweisen, wo eigentlich dessen Subtyp erwartet wird?
Um das zu zeigen, erweitern wir unser Beispiel um einen Typ für Altbenutzer (»legacy users«). Stellen Sie sich vor, Sie erweitern Code, den Ihr Kollege vor der Verwendung von TypeScript geschrieben hat, mit Typen:
type LegacyUser = {
id?: number | string
name: string
}
let legacyUser: LegacyUser = {
id: '793331',
name: 'Xin Yang'
}
deleteUser(legacyUser) // Error TS2345: Argument of type 'LegacyUser' is not
// assignable to parameter of type '{id?: number |
// undefined, name: string}'. Type 'string' is not
// assignable to type 'number | undefined'.
TypeScript beschwert sich, wenn wir eine Form mit einer Eigenschaft übergeben, deren Typ ein Supertyp des erwarteten Typs ist. Der Grund ist, dass id den Typ string | number | undefined hat und deleteUser nur mit Fällen umgehen kann, in denen id den Typ number | undefined hat.
TypeScript verhält sich wie folgt: Wenn Sie eine Form erwarten, können Sie auch einen Typ übergeben, dessen Eigenschaftstypen das Verhältnis <: zu den erwarteten Typen haben. Die Übergabe einer Form mit Eigenschaftstypen, die Subtypen des erwarteten Typs sind, ist dagegen nicht möglich. Auf Typen bezogen sagen wir, dass TypeScript-Formen (Objekte und Klassen) in ihren Eigenschaftstypen kovariant sind. Damit ein Objekt A einem Objekt B zugewiesen werden kann, müssen sämtliche Eigenschaften im Verhältnis <: zu den entsprechenden Eigenschaften in B stehen.
Eigentlich ist die Kovarianz nur eine von vier Varianzformen:
Invarianz
Sie wollen genau ein T.
Kovarianz
Sie wollen ein <:T.
Kontravarianz
Sie wollen ein >:T.
Bivarianz
Sowohl <:T als auch >:T sind in Ordnung.
In TypeScript ist jeder komplexe Typ kovariant mit seinen Membern, Objekten, Klassen, Array und Rückgabetypen von Funktionen. Es gibt allerdings eine Ausnahme: Funktionsparametertypen. Sie sind kontravariant.
Nicht alle Sprachen treffen die gleichen Designentscheidungen. In manchen Sprachen sind Objekte in Eigenschaftstypen invariant, da kovariante Eigenschaftstypen zu unsicherem Verhalten führen können, wie wir gesehen haben. Einige Sprachen haben unterschiedliche Regeln für mutable und immutable Objekte (den Grund können Sie sich denken!). Einige Sprachen (wie Scala, Kotlin und Flow) besitzen eine explizite Syntax, mit der Programmierer die Varianz Ihrer eigenen Datentypen festlegen können. |
|
|
Die Entwickler von TypeScript haben sich für ein Gleichgewicht zwischen einfacher Benutzbarkeit und Sicherheit entschieden. Es ist zwar sicherer, Objekte in ihren Eigenschaftstypen invariant zu machen, allerdings verschlechtert das auch die Benutzbarkeit. Schließlich werden Techniken verboten, die in der Praxis sicher verwendet werden können (hätten wir die id in deleteUser nicht per delete gelöscht, wäre es vollkommen sicher gewesen, ein Objekt zu übergeben, dessen Typ ein Supertyp des erwarteten Typs ist). |
Beginnen wir mit ein paar Beispielen.
Eine Funktion A ist ein Subtyp einer Funktion B, wenn A die gleiche oder eine niedrigere Arität (Anzahl der Parameter) hat als B und:
Lesen Sie das ruhig ein paarmal, bis Sie die Bedeutung der einzelnen Regeln verstehen. Damit eine Funktion A ein Subtyp der Funktion B sein kann, sagen wir, dass der Typ von As this und ihrer Parameter im Verhältnis >: zu ihren Entsprechungen in B stehen muss, ihr Rückgabetyp dagegen im Verhältnis <:! Warum ändert sich hier die Richtung? Warum gilt hier nicht <: für alle Bestandteile (die Typen von this, Parametern und des Rückgabewerts) wie es für Objekte, Arrays, Vereinigungsmengen etc. auch gilt?
Lassen Sie uns die Antwort selbst herausfinden. Wir beginnen mit der Definition von drei Typen (aus Gründen der Klarheit benutzen wir hier class, auch wenn dieses Vorgehen für alle möglichen Typen funktioniert, bei denen A <: B <: C gilt):
class Animal {}
class Bird extends Animal {
chirp() {}
}
class Crow extends Bird {
caw() {}
}
In diesem Beispiel ist Crow (Krähe) ein Subtyp von Bird (Vogel), das wiederum ein Subtyp von Animal (Tier) ist: Crow <: Bird <: Animal.
Als Nächstes definieren wir eine Funktion, die einen Vogel zum Zwitschern (chirp) bringt:
function chirp(bird: Bird): Bird {
bird.chirp()
return bird
}
Das ist schon nicht schlecht. Was können wir laut TypeScript an chirp übergeben?
chirp(new Animal) // Error TS2345: Argument of type 'Animal' is not assignable
chirp(new Bird) // to parameter of type 'Bird'.
chirp(new Crow)
Sie können entweder eine Instanz von Bird übergeben (wie im Parameter für den Typ von bird festgelegt) oder eine Instanz von Crow (weil es ein Subtyp von Bird ist). Prima, die Übergabe eines Subtyps funktioniert wie erwartet.
Jetzt schreiben wir eine neue Funktion. Diesmal wollen wir jedoch eine Funktion übergeben:
function clone(f: (b: Bird) => Bird): void {
// ...
}
clone benötigt eine Funktion f, die ein Bird-Objekt übernimmt und ein Bird-Objekt zurückgibt. Welche Funktionstypen können sicher an f übergeben werden? Auf jeden Fall eine Funktion, die einen Bird übernimmt und einen Bird wieder zurückgibt:
function birdToBird(b: Bird): Bird {
// ...
}
clone(birdToBird) // OK
Was ist mit einer Funktion, die ein Bird-Objekt übernimmt und ein Crow- oder Animal-Objekt zurückgibt?
function birdToCrow(d: Bird): Crow {
// ...
}
clone(birdToCrow) // OK
function birdToAnimal(d: Bird): Animal {
// ...
}
clone(birdToAnimal) // Error TS2345: Argument of type '(d: Bird) => Animal' is
// not assignable to parameter of type '(b: Bird) => Bird'.
// Type 'Animal' is not assignable to type 'Bird'.
birdToCrow funktioniert wie erwartet. birdToAnimal löst dagegen einen Fehler aus. Warum? Angenommen, unsere Implementierung von clone sähe so aus:
function clone(f: (b: Bird) => Bird): void {
let parent = new Bird
let babyBird = f(parent)
babyBird.chirp()
}
Hätten wir der clone-Funktion ein f übergeben, das ein Animal-Objekt zurückgibt, könnten wir .chirp daran nicht aufrufen! Deshalb stellt TypeScript bei der Kompilierung sicher, dass die übergebene Funktion mindestens ein Bird-Objekt zurückgibt.
Wir sagen, die Funktionen sind in ihren Rückgabetypen kovariant. Das heißt: Damit eine Funktion ein Subtyp einer anderen Funktion sein kann, muss ihr Rückgabetyp im Verhältnis <: zum Rückgabetyp der anderen Funktion stehen.
Und wie sieht es mit Parametertypen aus?
function animalToBird(a: Animal): Bird {
// ...
}
clone(animalToBird) // OK
function crowToBird(c: Crow): Bird {
// ...
}
clone(crowToBird) // Error TS2345: Argument of type '(c: Crow) => Bird' is not
// assignable to parameter of type '(b: Bird) => Bird'.
Damit eine Funktion an eine andere Funktion zugewiesen werden kann, müssen alle Parametertypen (inklusive this) im Verhältnis >: zu den Parametertypen der empfangenden Funktion stehen. Um das zu verstehen, stellen wir uns vor, wie jemand crowToBird implementiert hätte, bevor die Funktion an clone übergeben wird. Zum Beispiel so:
function crowToBird(c: Crow): Bird {
c.caw()
return new Bird
}
Wenn clone ein neues Bird-Objekt benutzt, um crowToBird aufzurufen, wird eine Ausnahme ausgelöst, weil .caw () (krächzen) nur für Crows (Krähen), aber nicht für alle Birds definiert ist.
Das heißt, Funktionen sind in ihren Typen für this und ihre Parameter kontravariant. Ist eine Funktion ein Subtyp einer anderen Funktion, müssen sämtliche Parameter und ihr this im Verhältnis >: zur anderen Funktion stehen.
Glücklicherweise müssen Sie sich diese Regeln nicht merken oder sie gar wiederholen. Denken Sie einfach daran, wenn Ihr Codeeditor wieder einmal eine rote Unterschlängelung anzeigt, weil Sie eine falsch typisierte Funktion übergeben haben.
TSC Flag: strictFunctionTypes Aus Gründen der Rückwärtskompatibilität sind TypeScript-Funktionen in ihren Parameter- und this-Typen tatsächlich kovariant. Um das gerade gesehene sicherere kontravariante Verhalten zu aktivieren, sollten Sie das Flag {"strictFunctionTypes": true} in Ihrer tsconfig.json aktivieren. Wenn Sie per {"strict": true} den strict-Modus nutzen, ist strict FunctionTypes bereits für Sie gesetzt, und Sie können direkt loslegen. |
Die Beziehungen zwischen Sub- und Supertypen sind Kernkonzepte jeder statisch typisierten Sprache. Sie spielen außerdem eine wichtige Rolle, wenn es um das Verständnis der Zuweisbarkeit geht. Damit sind die TypeScript-Regeln gemeint, die festlegen, ob Sie einen Typ A verwenden können, wo eigentlich ein Typ B benötigt wird.
Um die Frage »Ist Typ A an Typ B zuweisbar?« zu beantworten, folgt TypeScript ein paar einfachen Regeln. Für alle Typen, die kein Enum sind (also Arrays, boolesche Werte, Zahlen, Objekte, Funktionen, Klassen, Klasseninstanzen und Strings, inklusive literaler Typen) gilt: A ist an B zuweisbar, wenn eine der folgenden Regeln wahr ist:
Regel 1 ist einfach die Definition eines Subtyps: Ist A ein Subtyp von B, dann können Sie B überall verwenden, wo sonst ein A benutzt wird.
Regel 2 ist die Ausnahme zu Regel 1 und soll die Zusammenarbeit mit JavaScript-Code erleichtern.
Für Enum-Typen, die mit den Schlüsselwörtern enum oder const enum erzeugt wurden, ist A an B zuweisbar, wenn eine der folgenden Regeln zutrifft:
Regel 1 ist exakt die gleiche wie für einfache Typen (ist A ein Member des Enums B, dann sind der Typ von A und der Typ von B gleich; wir sagen im Prinzip nur: B <: B).
Regel 2 soll die Arbeit mit Enums erleichtern. Wie in »Enums« auf Seite 40 erklärt wurde, kann Regel 2 für viel Unsicherheit in TypeScript sorgen. Dies ist der einzige Grund, warum ich empfehle, das Kind mit dem Bade auszuschütten und Enums komplett zu vermeiden.
Die Typerweiterung (type widening) ist der Schlüssel zum Verständnis von TypeScripts Typableitung. Allgemein ist TypeScript bei der Ableitung von Typen eher nachsichtig und irrt sich manchmal, indem es einen allgemeineren Typ anstelle eines möglichst spezifischen Typs ableitet. Das erleichtert Ihr Leben als Programmierer, weil Sie sich nicht so oft mit dem Genörgel des Typecheckers herumschlagen müssen.
Unter Kapitel 3 haben Sie bereits ein paar Beispiele für die Typerweiterung in Aktion gesehen. Sehen wir uns hierzu ein paar weitere Beispiele an.
Wenn Sie eine Variable (etwa mit let oder var) so deklarieren, dass sie später verändert werden kann, wird ihr Typ von ihrem literalen Wert auf den Basistyp des Literals erweitert:
let a = 'x' // string
let b = 3 // number
var c = true // boolean
const d = {x: 3} // {x: number}
enum E {X, Y, Z}
let e = E.X // E
Bei immutablen Deklarationen funktioniert das nicht:
const a = 'x' // 'x'
const b = 3 // 3
const c = true // true
enum E {X, Y, Z}
const e = E.X // E.X
Sie können die Typerweiterung verhindern, indem Sie explizite Typannotationen verwenden:
let a: 'x' = 'x' // 'x'
let b: 3 = 3 // 3
var c: true = true // true
const d: {x:3} = {x: 3} // {x: 3}
Wenn Sie einen nicht erweiterten Typ per let oder var neu zuweisen, erweitert TypeScript ihn bei Bedarf für Sie. Um TypeScript davon abzuhalten, erweitern Sie Ihre ursprüngliche Deklaration um eine explizite Annotation:
Mit null oder undefined initialisierte Variablen werden zu any erweitert:
let a = null // any
a = 3 // any
a = 'b' // any
Wenn eine mit null oder undefinedinitialisierte Variable den Geltungsbereich verlässt, in dem sie deklariert wurde, weist TypeScript ihr dagegen einen definitiven Typ zu:
function x() {
let a = null // any
a = 3 // any
a = 'b' // any
return a
}
x() // string
TypeScript besitzt einen speziellen Typ namens const. Diesen können Sie verwenden, um die Typerweiterung für bestimmte Deklarationen zu umgehen. Verwenden Sie const als Typzusicherung (mehr dazu unter »Typzusicherungen (type assertions)« auf Seite 149):
let a = {x:3} // {x: number}
let b: {x:3} // {x: 3}
let c = {x:3} as const // {readonly x: 3}
const verhindert die Erweiterung Ihres Typs und markiert seine Member rekursiv (also auch für verschachtelte Datenstrukturen) per readonly als schreibgeschützt:
let d = [1, {x: 2}] // (number | {x: number})[]
let e = [1, {x: 2}] as const // readonly [1, {readonly x: 2}]
Verwenden Sie as const, wenn TypeScript ihren Typ so eng wie möglich ableiten soll.
Die Typerweiterung spielt auch eine Rolle, wenn TypeScript überprüft, ob ein Objekttyp einem anderen zuweisbar ist.
In »Form und Arrayvarianz« auf Seite 118 haben wir gesehen, dass Objekttypen in ihren Membern kovariant sind. Wenn TypeScript sich aber ohne zusätzliche Tests streng an diese Regel hielte, könnte das zu Problemen führen.
Nehmen wir zum Beispiel ein Options-Objekt, das Sie an eine Klasse übergeben wollen, um sie zu konfigurieren:
type Options = {
baseURL: string
cacheSize?: number
tier?: 'prod' | 'dev'
}
class API {
constructor(private options: Options) {}
}
new API({
baseURL: 'https://api.mysite.com',
tier: 'prod'
})
Was passiert bei einem Tippfehler?
new API({
baseURL: 'https://api.mysite.com',
tierr: 'prod' // Error TS2345: Argument of type '{tierr: string}'
}) // is not assignable to parameter of type 'Options'.
// Object literal may only specify known properties,
// but 'tierr' does not exist in type 'Options'.
// Did you mean to write 'tier'?
In JavaScript kann das schnell passieren. Daher ist es besonders praktisch, dass TypeScript uns bei der Fehlersuche hilft. Aber wie kann TypeScript das überhaupt erkennen, wenn Objekttypen in ihren Members kovariant sind?
Durch die Überprüfung auf zusätzliche Eigenschaften (excess property checking) konnte TypeScript das Problem erkennen. Die Überprüfung funktioniert in etwa so: Wenn Sie einen »frischen« literalen Objekttyp T einem anderen Typ U zuweisen und T Eigenschaften besitzt, die es in U nicht gibt, dann gibt TypeScript einen Fehler aus.
Einen »frischen« literalen Objekttyp leitet TypeScript von einem Objektliteral ab. Verwendet das Objektliteral eine Typzusicherung (siehe »Typzusicherungen (type assertions)« auf Seite 149) oder wird es einer Variablen zugewiesen, dann wird der frische literale Objekttyp zu einem regulären Objekttyp erweitert, und er gilt nicht mehr als »frisch«.
Diese Definition ist recht komplex. Daher wollen wir uns hierzu ein weiteres Beispiel ansehen und ein paar Variationen des Themas ausprobieren:
baseURL: string
cacheSize?: number
tier?: 'prod' | 'dev'
}
class API {
constructor(private options: Options) {}
}
new API({
baseURL: 'https://api.mysite.com',
tier: 'prod'
})
new API({
baseURL: 'https://api.mysite.com',
badTier: 'prod' // Error TS2345: Argument of type '{baseURL: string; badTier:
}) // string}' is not assignable to parameter of type 'Options'.
new API({
baseURL: 'https://api.mysite.com',
badTier: 'prod'
} as Options)
let badOptions = {
baseURL: 'https://api.mysite.com',
badTier: 'prod'
}
new API(badOptions)
let options: Options = {
baseURL: 'https://api.mysite.com',
badTier: 'prod' // Error TS2322: Type '{baseURL: string; badTier: string}'
} // is not assignable to type 'Options'.
new API(options)
Keine Sorge. Sie brauchen sich diese Regeln nicht zu merken. Das sind TypeScripts interne Mechanismen, um möglichst viele Fehler zu finden, damit Sie als Programmierer sich nicht damit befassen müssen. Behalten Sie sie einfach im Hinterkopf, falls Sie sich wundern, wie TypeScript es schafft, sich über einen Fehler zu beschweren, den selbst Ivan, der kampferprobte Bewacher der Codebasis Ihres Unternehmens, nicht bemerkt hätte.
Die Typableitung von TypeScript ist flussbasiert. Das ist eine Art symbolische Ausführung. Hierbei verwendet der Typechecker Kontrollfluss-Anweisungen wie if, ?, || und switch sowie Typabfragen wie typeof, instanceof und in, um die Typen während seiner Arbeit zu verfeinern (refine), wie es ein Programmierer beim Lesen des Codes auch tun würde.1
Diese Fähigkeit des Typecheckers kann sehr praktisch sein und ist nur in wenigen Sprachen möglich.2
Sehen wir uns hierzu ein Beispiel an. Angenommen, wir haben eine API für die Definition von CSS-Regeln erstellt und ein Mitarbeiter will damit die width-Eigenschaft eines HTML-Elements festlegen. Er übergibt einen entsprechenden Wert, der anschließend geparst und überprüft werden soll.
Wir beginnen mit der Implementierung einer Funktion, die einen CSS-String in einen Wert und einige mögliche Maßeinheit aufteilt:
// Wir benutzen eine Vereinigungsmenge aus Strings, um
// die möglichen CSS-Maßeinheiten anzugeben
type Unit = 'cm' | 'px' | '%'
// Die möglichen Werte werden enumeriert
let units: Unit[] = ['cm', 'px', '%']
// Einheiten überprüfen und null ausgeben, wenn es keinen Treffer gab
function parseUnit(value: string): Unit | null {
for (let i = 0; i < units.length; i++) {
if (value.endsWith(units[i])) {
return units[i]
}
}
return null
}
Jetzt können wir parseUnit verwenden, um einen vom Benutzer übergebenen Wert für die Breite zu parsen. Dabei kann width eine Zahl (die wir als Pixel interpretieren) sein, ein String, der mit einer bestimmten Maßeinheit endet, oder auch null oder undefined.
In diesem Beispiel kommt die Typverfeinerung gleich mehrmals zum Einsatz:
type Width = {
unit: Unit,
value: number
}
function parseWidth(width: number | string | null | undefined): Width | null {
// Ist width null oder undefined, kehrt die Funktion sofort zurück
if (width == null) {
return null
}
// Enthält width nur eine Zahl, nehmen wir Pixel als Einheit an
if (typeof width === 'number') {
return {unit: 'px', value: width}
}
// Versuchen, aus width eine Maßeinheit zu ermitteln
let unit = parseUnit(width)
if (unit) {
return {unit, value: parseFloat(width)}
}
// Ansonsten null zurückgeben
return null
}
Ich habe hier noch einmal genau erklärt, welche Typverfeinerungen TypeScript durchgeführt hat. Ich hoffe allerdings, dass Ihnen als Programmierer, der den Code liest, dies ohnehin schon klar war. TypeScript ist besonders gut darin, Ihre Gedanken beim Schreiben und Lesen von Code zu erkennen und in Typechecking- und Ableitungsregeln umzusetzen.
Wie wir gerade gesehen haben, besitzt TypeScript ein tiefgehendes Verständnis von der Funktionsweise von JavaScript. Dadurch ist es in der Lage, Ihnen bei der Verfeinerung Ihrer Typen so zu folgen, als würden Sie das Programm im Kopf durchgehen.
Im nächsten Beispiel bauen wir ein spezielles Eventsystem für eine Applikation. Wir beginnen mit der Definition einiger Event-Typen und einer Funktion, die eingehende Events verarbeiten kann. Hierbei modelliert UserTextEvent eine Tastatureingabe (z.B. indem der Benutzer etwas in ein <input />-Feld eingibt), während UserMouseEvent für ein Maus-Event steht (z.B. indem der Benutzer den Mauszeiger zu den Koordinaten [100, 200] bewegt):
type UserTextEvent = {value: string}
type UserMouseEvent = {value: [number, number]}
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
if (typeof event.value === 'string') {
event.value // string
return
}
event.value // [number, number]
}
TypeScript weiß (durch den Test per typeof), dass event.value innerhalb des if-Blocks den Typ string haben muss. Das heißt, nach dem if-Block muss event.value ein Tupel des Typs [number, number] sein (wegen der return-Anweisung im if-Block).
Was passiert, wenn wir das etwas komplizierter machen? Wir erweitern unsere Event-Typen um zusätzliche Informationen, um zu sehen, wie TypeScript sich schlägt, wenn wir unsere Typen verfeinern:
type UserTextEvent = {value: string, target: HTMLInputElement}
type UserMouseEvent = {value: [number, number], target: HTMLElement}
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
if (typeof event.value === 'string') {
event.value // string
event.target // HTMLInputElement | HTMLElement (!!!)
// ...
return
}
event.value // [number, number]
event.target // HTMLInputElement | HTMLElement (!!!)
}
Obwohl die Verfeinerung für event.value funktioniert hat, schlägt sie für event.target fehl. Aber warum? Wenn die handle-Funktion einen Parameter des Typs UserEvent übernimmt, sind wir nicht gezwungen, explizit einen UserTextEvent oder UserMouseEvent zu übergeben. Der übergebene Typ hätte auch UserMouseEvent | UserTextEvent lauten können. Da sich die Member eines Vereinigungstyps überschneiden können, benötigt TypeScript eine verlässlichere Möglichkeit, herauszufinden, ob es sich um einen Vereinigungstyp oder einen anderen Fall handelt.
Dafür wird jede mögliche Variante des Vereinigungstyps mit einem literalen Typ oder »Tag« markiert. Ein gutes Tag hat folgende Eigenschaften:
Mit diesem Wissen können wir unsere Eventtypen weiter verbessern:
type UserTextEvent = {type: 'TextEvent', value: string, target: HTMLInputElement}
type UserMouseEvent = {type: 'MouseEvent', value: [number, number],
target: HTMLElement}
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
if (event.type === 'TextEvent') {
event.value // string
event.target // HTMLInputElement
// ...
return
}
event.value // [number, number]
event.target // HTMLElement
}
Wenn wir den Typ von event anhand des Werts des markierten Felds (event.type) verfeinern, weiß TypeScript, dass event im if-Zweig den Typ UserTextEvent haben und nach dem if-Zweig ein UserMouseEvent sein muss. Da das Tag per Vereinigungstyp einmalig ist, weiß TypeScript, dass beide Typen sich gegenseitig ausschließen.
Verwenden Sie markierte Vereinigungstypen beim Schreiben von Funktionen, die mit unterschiedlichen Fällen einer Vereinigung umgehen müssen. Für die Arbeit mit Flux-Actions, Redux-Reactions und Reacts useReducer-Hook sind sie von unschätzbarem Wert.
Ein Programmierer stellt vor dem Schlafengehen zwei Gläser auf den Nachttisch: ein volles, falls er Durst bekommt, und ein leeres, falls nicht.
– Anonym
Totalität, auch als Vollständigkeitsprüfung (exhaustiveness checking) bezeichnet, ermöglicht es dem Typechecker, zu überprüfen, ob Sie wirklich alle Fälle abgedeckt haben. Dieses Konzept stammt aus Haskell, OCaml und anderen Sprachen, die auf Mustererkennung basieren.
TypeScript testet in verschiedenen Situationen auf Vollständigkeit und gibt Ihnen hilfreiche Warnungen, wenn Sie einen Fall übersehen haben. Das ist eine große Unterstützung bei der Fehlervermeidung. Zum Beispiel:
type Weekday = 'Mon' | 'Tue'| 'Wed' | 'Thu' | 'Fri'
type Day = Weekday | 'Sat' | 'Sun'
Wir haben offenbar ein paar Tage ausgelassen (die Woche war ziemlich lang). Hier kommt uns TypeScript zu Hilfe:
Error TS2366: Function lacks ending return statement and
return type does not include 'undefined'.
TSC Flag: noImplicitReturns Sie können TypeScript anweisen, zu überprüfen, ob alle Ihre Funktionen einen Wert zurückgeben (und bei Bedarf eine entsprechende Warnung ausgeben). Aktivieren Sie hierfür das Flag noImplicitReturns in Ihrer tsconfig.json. Ob Sie das tun, ist Ihre Entscheidung. Manche Programmierer verwenden lieber weniger explizite return-Anweisungen, anderen haben kein Problem damit, wenn es der Typsicherheit dient und der Typechecker mehr Fehler finden kann. |
Diese Fehlermeldung teilt uns mit, dass wir entweder einige Fälle übergangen haben, die Sie mit einer allgemeinen (»catchall«) return-Anweisung behandeln können oder bei der Sie den Rückgabetyp anpassen sollten. Im ersten Fall könnte der Rückgabewert etwas sein wie 'Sat' (das wäre schön, was?); im zweiten Fall könnte der Typ Day | undefined lauten. Nachdem wir für jeden Wochentag eine case-Anweisung eingebaut haben, tritt die Warnmeldung nicht mehr auf (probieren Sie’s!). Da wir den Rückgabetyp von getNextDay annotiert haben und nicht alle Zweige garantiert einen Wert dieses Typs zurückgeben, warnt TypeScript uns.
Die Details der Implementierung sind in diesem Beispiel nicht wichtig: Egal, welche Kontrollstruktur Sie verwenden – switch, if, throw etc. –, TypeScript passt auf Sie auf, damit Sie nicht versehentlich etwas vergessen.
Hier ein weiteres Beispiel:
function isBig(n: number) {
if (n >= 100) {
return true
}
}
Vielleicht haben Sie die ständigen Sprachnachrichten eines Kunden durcheinandergebracht und vergessen, Zahlen unter 100 in Ihrer geschäftskritischen isBig-Funktion zu berücksichtigen. Auch hier brauchen Sie keine Sorge zu haben: TypeScript ist für Sie da:
Error TS7030: Not all code paths return a value.
Oder Sie konnten das Wochenende nutzen, um den Kopf freizubekommen, und Ihnen ist eingefallen, dass Sie das getNextDay-Beispiel noch effizienter schreiben können. Wie wäre es mit festen Werten anstelle einer switch-Anweisung?
Vielleicht hat das Gekläffe von Nachbars Bichon Frise Sie abgelenkt und Sie haben vergessen, die anderen Tage ins neue nextDay-Objekt einzubauen, bevor Sie den Code veröffentlicht und mit wichtigeren Dingen weitergemacht haben.
Auch wenn TypeScript Sie beim Zugriff auf nextDay.Tue auf den Fehler aufmerksam macht, hätten Sie bei der Deklaration von nextDay auch gleich etwas proaktiver sein können. Dafür gibt es zwei Wege, wie wir in »Der Record-Typ« auf Seite 138 und »Abgebildete Typen« auf Seite 139 sehen werden. Zuvor machen wir aber noch einen kleinen Ausflug zu den Typoperatoren für Objekttypen.
Objekte sind ein zentrales Konzept in JavaScript. Daher gibt Ihnen TypeScript eine ganze Reihe von Möglichkeiten, sie auszudrücken und auf sichere Weise zu bearbeiten.
Erinnern Sie sich noch an die beiden Typoperatoren | und &, die ich in »Vereinigungs- und Schnittmengentypen« auf Seite 32 vorgestellt habe? Davon gibt es in TypeScript noch eine ganze Menge mehr. Sehen wir uns ein paar weitere Typoperatoren an, die bei Ihrer Arbeit mit Formen hilfreich sein können. | (Pipe-Zeichen), für Vereinigungstypen
Angenommen, Sie haben einen komplexen verschachtelten Typ erstellt, um die Antwort einer GraphQL-API zu modellieren, die Sie von einer Socical-Media-API Ihrer Wahl zurückbekommen haben:
type APIResponse = {
user: {
userId: string
friendList: {
count: number
friends: {
firstName: string
lastName: string
}[]
}
}
}
Danach können Sie die Antwort der API entgegennehmen und rendern:
function getAPIResponse(): Promise<APIResponse= {
// ...
}
function renderFriendList(friendList: unknown) {
// ...
}
let response = await getAPIResponse()
renderFriendList(response.user.friendList)
Welchen Typ sollte friendsList in diesem Fall haben? (Im Moment haben wir den Typ als unknown skizziert.) Sie könnten das durchtypisieren und den Typ Ihrer APIResponse entsprechend neu implementieren:
type FriendList = {
count: number
friends: {
firstName: string
lastName: string
}[]
}
type APIResponse = {
user: {
userId: string
friendList: FriendList
}
}
function renderFriendList(friendList: FriendList) {
// ...
}
Dann müsste Sie aber jedem Typ auf der obersten Ebene mit einem Namen versehen, was nicht immer gewünscht ist (vielleicht haben Sie ein Build-Werkzeug benutzt, um die TypeScript-Typen aus dem GraphQL-Schema zu erzeugen). Stattdessen können Sie den Typ schlüsselbasiert angeben.
type APIResponse = {
user: {
userId: string
friendList: {
count: number
friends: {
firstName: string
lastName: string
}[]
}
}
}
type FriendList = APIResponse['user']['friendList']
function renderFriendList(friendList: FriendList) {
// ...
}
Der schlüsselbasierte Zugriff funktioniert für beliebige Formen (Objekte, Klassenkonstruktoren oder Klasseninstanzen) und jede Art von Array. Um an den Typ eines einzelnen Freunds zu kommen, könnten Sie beispielsweise schreiben:
type Friend = FriendList['friends'][number]
number ist hierbei der Schlüssel zum gewünschten Arraytyp. Für Tupel verwenden Sie 0, 1 oder eine andere Zahl, die dem benötigten Index entspricht.
Die Ähnlichkeit der Syntax für den schlüsselbasierten Zugriff mit dem Zugriff auf Felder in JavaScript-Objekte ist beabsichtigt. Der Typ einer Form kann auf die gleiche Weise ermittelt werden wie der Wert in einem Objekt. Beachten Sie, dass Sie für den schlüsselbasierten Zugriff eckige Klammern anstelle der Punktnotation verwenden müssen.
Verwenden Sie keyof, um alle Schlüssel eines Objekts als Vereinigungsmenge aus literalen Stringtypen zu erhalten. Das vorige APIResponse-Beispiel würde dann so aussehen:
type ResponseKeys = keyof APIResponse // 'user'
type UserKeys = keyof APIResponse['user'] // 'userId' | 'friendList'
type FriendListKeys =
keyof APIResponse['user']['friendList'] // 'count' | 'friends'
Durch die Kombination aus schlüsselbasiertem Zugriff und keyof-Operatoren können Sie eine typsichere Getter-Funktion implementieren, die den Wert eines bestimmten Schlüssels in einem Objekt ausliest:
function get<
O extends object,
K extends keyof O
>(
o: O,
k: K
): O[K] {
return o[k]
}
Mit diesen Typoperatoren können Sie präzise und sicher beschreiben, welchen Typ die Formen haben:
type ActivityLog = {
lastEvent: Date
events: {
id: string
timestamp: Date
type: 'Read' | 'Write'
}[]
}
let activityLog: ActivityLog = // ...
let lastEvent = get(activityLog, 'lastEvent') // Date
Hierbei verifiziert TypeScript bei der Kompilierung, dass der lastEvent den Typ Date hat. Natürlich können Sie das erweitern, um auf die Schlüssel eines tiefer liegenden Objekts zugreifen zu können. Im nächsten Beispiel überladen wir get, sodass drei Schlüssel übernommen werden:
type Get = {
<
O extends object,
K1 extends keyof O
>(o: O, k1: K1): O[K1]
<
O extends object,
K1 extends keyof O,
K2 extends keyof O[K1]
>(o: O, k1: K1, k2: K2): O[K1][K2]
<
O extends object,
K1 extends keyof O,
K2 extends keyof O[K1],
K3 extends keyof O[K1][K2]
>(o: O, k1: K1, k2: K2, k3: K3): O[K1][K2][K3]
}
let get: Get = (object: any, ...keys: string[]) => {
let result = object
keys.forEach(k => result = result[k])
return result
}
get(activityLog, 'events', 0, 'type') // 'Read' | 'Write'
get(activityLog, 'bad') // Error TS2345: Argument of type '"bad"'
// is not assignable to parameter of type
// '"lastEvent" | "events"'.
Nicht schlecht, was? Wenn Sie eine Minute Zeit haben, zeigen Sie dieses Beispiel Ihren Java-Freunden. Und verbergen Sie nicht Ihre Freude, wenn Sie den Code zusammen durchgehen.
TSC Flag: keyofStringsOnly In JavaScript können Objekte und Arrays sowohl Strings wie auch Symbole als Schlüssel verwenden. Per Konventionen benutzen wir für Arrays normalerweise zahlenbasierte Schlüssel, die zur Laufzeit in Strings umgewandelt werden. Aus diesem Grund gibt die TypeScript-Version von keyof standardmäßig den Typ number | string | symbol zurück. (Wenn Sie keyof dagegen an einer spezifischeren Form aufrufen, kann TypeScript auch einen spezifischeren Subtyp dieser Vereinigungsmenge ableiten). |
|
|
Dieses Verhalten ist korrekt. Allerdings wird für die Arbeit mit keyof viel Code gebraucht, weil Sie TypeScript erst beweisen müssen, dass der eine Schlüssel, mit dem Sie gerade arbeiten, den Typ string hat und nicht etwa number oder symbol. |
|
Früher mussten die Schlüssel in TypeScript Strings sein. Um dieses Verhalten zu nutzen, können Sie das keyofStringsOnly-Flag in Ihrer tsconfig.json aktivieren. |
Der TypeScript-eigene Typ Record ist ein Weg, ein Objekt als »Map« zu beschreiben, die eine Sache auf eine andere abbildet.
Im Weekday-Beispiel des Abschnitts »Totalität« auf Seite 132 haben wir gesehen, dass es zwei Möglichkeiten gibt, zu erzwingen, dass ein Objekt ganz bestimmte Schlüssel definiert. Record-Typen sind die erste Möglichkeit.
Im folgenden Beispiel verwenden wir Record, um eine Map zu erstellen, die jeden Wochentag auf den jeweils folgenden abbildet. Mit Record können Sie die Schlüssel und Werte in nextDay beschränken:
type Weekday = 'Mon' | 'Tue'| 'Wed' | 'Thu' | 'Fri'
type Day = Weekday | 'Sat' | 'Sun'
let nextDay: Record<Weekday, Day> = {
Mon: 'Tue'
}
Und schon erhalten Sie eine nette, hilfreiche Fehlermeldung:
Error TS2739: Type '{Mon: "Tue"}' is missing the following properties
from type 'Record<Weekday, Day>': Tue, Wed, Thu, Fri.
Sobald wir unserem Objekt die fehlenden Wochentage (Weekdays) hinzufügen, verschwinden auch die Fehler.
Im Vergleich zu regulären Indexsignaturen gibt Record Ihnen mehr Freiheit: Bei regulären Indexsignaturen können Sie zwar die Typen der Objektwerte beschränken, der Schlüssel kann aber nur den regulären Typ string, number oder symbol haben. Mit Record können Sie die Typen eines Objektschlüssels auf Subtypen von string und number beschränken.
TypeScript besitzt noch einen zweiten, mächtigeren Weg, einen sichereren Typ für nextDay zu deklarieren: abgebildete Typen (mapped types). Im folgenden Beispiel verwenden wir abgebildete Typen, um festzulegen, dass nextDay ein Objekt ist, das für jeden Wochentag (Weekday) einen Schlüssel vom Typ Day besitzt:
let nextDay: {[K in Weekday]: Day} = {
Mon: 'Tue'
}
Dies ist eine weitere Möglichkeit, einen hilfreichen Hinweis auf mögliche Fehler zu erhalten:
Error TS2739: Type '{Mon: "Tue"}' is missing the following properties
from type '{Mon: Weekday; Tue: Weekday; Wed: Weekday; Thu: Weekday;
Fri: Weekday}': Tue, Wed, Thu, Fri.
Abgebildete Typen gibt es nur in TypeScript. Wie literale Typen sind sie ein Hilfsmittel, das nur einen Sinn ergibt, wenn man versucht, JavaScript statisch zu typisieren.
Dabei haben abgebildete Typen ihre spezielle Syntax. Und wie bei Indexsignaturen kann es höchstens einen abgebildeten Typ pro Objekt geben:
Wie der Name schon sagt, ist dies eine Art, ein »Mapping« über die Typen von Schlüsseln und Werten auszuführen. Tatsächlich verwendet TypeScript abgebildete Typen, um seinen Record-Typ zu implementieren.
type Record<K extends keyof any, T> = {
[P in K]: T
}
Abgebildete Typen sind leistungsstärker als einfache Record-Typen, da Sie nicht nur Schlüssel und Werte eines Objekts mit Typen versehen können. Wenn Sie abgebildete Typen mit dem schlüsselbasierten Zugriff kombinieren, können Sie genau festlegen, welcher Wertetyp zu welchem Schlüsselnamen gehört.
Hier ein paar Beispiele für die Dinge, die Sie mit abgebildeten Typen tun können:
type Account = {
id: number
isEmployee: boolean
notes: string[]
}
// Alle Felder als optional kennzeichnen
type OptionalAccount = {
[K in keyof Account]?: Account[K]
}
// Alle Felder nullwertfähig machen
type NullableAccount = {
[K in keyof Account]: Account[K] | null
}
// Alle Felder als schreibgeschützt kennzeichnen
type ReadonlyAccount = {
readonly [K in keyof Account]: Account[K]
}
// Alle Felder wieder beschreibbar machen (äquivalent zu Account)
type Account2 = {
-readonly [K in keyof ReadonlyAccount]: Account[K]
}
// Alle Felder wieder erforderlich machen (äquivalent mit Account)
type Account3 = {
[K in keyof OptionalAccount]-?: Account[K]
}
Das Gegenstück zum Minus-Operator (-) ist der Plus-Operator (+). Sie werden ihn vermutlich nie direkt verwenden, weil er implizit ist: In einem abgebildeten Typ entspricht readonly der Schreibweise +readonly, und ? kann man auch als +? definieren. + existiert nur der Vollständigkeit halber. |
Die im vorigen Abschnitt vorgestellten abgebildeten Typen sind so nützlich, dass viele von ihnen direkt in TypeScript integriert sind:
Record<Keys, Values>
Ein Objekt mit Schlüsseln vom Typ Keys und Werten des Typs Values
Partial<Object>
Markiert alle Felder in Object als optional
Required<Object>
Markiert alle Felder in Object als erforderlich
Readonly<Object>
Markiert alle Felder in Object als schreibgeschützt
Pick<Object, Keys>
Gibt, basierend auf Keys, einen Subtyp von Object zurück
Das Companion-Objektmuster ist aus Scala (http://bit.ly/2I9Nqg2) bekannt und stellt eine Möglichkeit dar, gleichnamige Objekte und Klassen miteinander zu kombinieren. In TypeScript gibt es ein ähnlich nützliches Muster, das hier ebenfalls »Companion-Objektmuster« heißt. Wir verwenden es, um einen Typ und ein Objekt miteinander zu verknüpfen.
type Currency = {
unit: 'EUR' | 'GBP' | 'JPY' | 'USD'
value: number
}
let Currency = {
DEFAULT: 'USD',
from(value: number, unit = Currency.DEFAULT): Currency {
return {unit, value}
}
}
Wie Sie wissen, verwendet TypeScript für Typen und Werte separate Namensräume. Mehr hierzu finden Sie unter »Deklarationsverschmelzung (declaration merging)« auf Seite 229. Das heißt, im gleichen Geltungsbereich kann der gleiche Name (hier: Currency) sowohl an einen Typ als auch an einen Wert gebunden sein. Anhand des Companion-Objektmusters können wir diese getrennten Namensräume nutzen, um einen Namen zweimal zu deklarieren: zuerst als Typ und dann als Wert.
Dieses Muster hat einige praktische Eigenschaften: Sie können Informationen zu Typ und Wert unter einem Namen (z.B. Currency) gruppieren. Außerdem kann ein Consumer beide gleichzeitig importieren.
import {Currency} from './Currency'
let amountDue: Currency = {
unit: 'JPY',
value: 83733.10
}
let otherAmountDue = Currency.from(330, 'EUR')
Verwenden Sie das Companion-Objektmuster, wenn ein Objekt und ein Typ semantisch verwandt sind und das Objekt die Methoden für die Arbeit mit dem Typ bereitstellt.
Sehen wir uns nun ein paar fortgeschrittene Techniken für die Arbeit mit Funktionstypen an.
Wenn Sie in TypeScript einen Tupel deklarieren, ist TypeScript bei der Ableitung des Tupel-Typs nachsichtig. Basierend auf dem, was Sie übergeben haben, wird es einen möglichst allgemeinen Typ ableiten. Dabei ignoriert TypeScript die Länge des Tupels und auch, an welcher Position sich welcher Typ befindet:
let a = [1, true] // (number | boolean)[]
Gelegentlich soll die Ableitung trotzdem strenger sein, und a soll als Tupel mit fester Länge und nicht als Array behandelt werden. Natürlich könnten Sie eine Typzusicherung verwenden, um Ihrem Tupel explizit den gewünschten Typ zuzuweisen (mehr dazu unter »Typzusicherungen (type assertions)« auf Seite 149). Oder Sie könnten Ihren Tupel per as const (siehe »Der Typ const« auf Seite 125) als schreibgeschützt markieren und dadurch dafür sorgen, dass sein Typ möglichst eng abgeleitet wird.
Aber was tun Sie, wenn Sie beide Möglichkeiten vermeiden wollen und den Tupel dennoch als solchen typisieren wollen? Dann können Sie darauf zurückgreifen, wie TypeScript Typen für Restparameter ableitet (mehr dazu finden Sie unter »Begrenzten Polymorphismus für die Modellierung von Arität verwenden« auf Seite 78):
function tuple<
T extends unknown[]
>(
...ts: T
): T {
return ts
}
let a = tuple(1, true) // [number, boolean]
Diese Technik kann Ihnen helfen, Typzusicherungen zu vermeiden, wenn Ihr Code viele Tupel-Typen verwendet.
Für manche Funktionen, die boolesche Werte zurückgeben, reicht es nicht, den Rückgabetyp einfach als boolean festzulegen. Als Beispiel schreiben wir eine Funktion, die mitteilt, ob Sie einen string übergeben haben oder nicht:
function isString(a: unknown): boolean {
return typeof a === 'string'
}
isString('a') // ergibt true
isString([7]) // ergibt false
So weit, so gut. Was passiert, wenn Sie isString in echtem Code verwenden?
function parseInput(input: string | number) {
let formattedInput: string
if (isString(input)) {
formattedInput = input.toUpperCase() // Error TS2339: Property 'toUpperCase'
} // does not exist on type 'number'.
}
Was ist hier los? Wenn typeof für die reguläre Typverfeinerung funktioniert (siehe »Typverfeinerung (refinement)« auf Seite 128), warum dann nicht auch hier?
Das Problem ist, dass die Typverfeinerung nur den Typ einer Variablen im aktuellen Geltungsbereich verfeinern kann. Beim Verlassen des aktuellen Geltungsbereichs wird die Verfeinerung nicht in den neuen Geltungsbereich übernommen. In unserer isString-Implementierung haben wir typeof verwendet, um den Typ des Eingabeparameters zu string zu verfeinern. Da die Typverfeinerung aber nur im aktuellen Geltungsbereich funktioniert, wird sie nicht übernommen und geht verloren. TypeScript weiß nur, dass isString einen Wert vom Typ boolean zurückgegeben hat.
Wir können dem Typechecker aber mitteilen, dass isString nicht nur einen Wert vom Typ boolean zurückgibt, sondern dass das an isString übergebene Argument – sofern dieser boolesche Wert wahr (true) ist – den Typ string hat.
function isString(a: unknown): a is string {
return typeof a === 'string'
}
Type Guards sind Teil von TypeScript. Durch sie können Sie Typen mit typeof und instanceof verfeinern. Manchmal müssen Sie aber auch eigene Type Guards deklarieren, und genau dafür gibt es den is-Operator. Wenn Sie eine Funktion haben, die ihre Parametertypen verfeinert und einen Wert vom Typ boolean zurückgibt, können Sie einen benutzerdefinierten Type Guard verwenden, um sicherzustellen, dass die Verfeinerung überallhin übernommen wird, wo Sie diese Funktion benutzen.
Benutzerdefinierte Type Guards können nur einen Parameter haben, sind aber nicht auf einfache Typen beschränkt:
type LegacyDialog = // ...
type Dialog = // ...
function isLegacyDialog(
dialog: LegacyDialog | Dialog
): dialog is LegacyDialog {
// ...
}
Benutzerdefinierte Type Guards kommen nicht oft zum Einsatz. Falls doch, sind sie aber großartig. Ohne sie müssen Sie alle typeof- oder instanceof-Tests explizit verwenden. Mit Type Guards können Sie dagegen Funktionen wie isLegacyDialog und isString benutzen, um diese Tests in einer besser verkapselten und lesefreundlicheren Art durchzuführen.
Konditionale (bedingungsabhängige) Typen sind vermutlich das Merkmal, das TypeScript wirklich einmalig macht. An der Oberfläche können Sie mit konditionalen Typen Aussagen treffen wie: »Deklariere einen Typ T, der von den Typen U und V abhängt; falls U <: V, weise T an A zu, ansonsten weise T an B zu.«
Der Code könnte etwa so aussehen:
type IsString<T> = T extends string
? true
: false
type A = IsString<string> // true
type B = IsString<number> // false
Das sehen wir uns am besten der Reihe nach an:
Die Syntax sieht aus wie ein einfacher ternärer Ausdruck auf Wertebene, nur dass wir hier auf Typebene arbeiten. Und wie normale ternäre Ausdrücke können sie auch auf Typebene verschachtelt werden.
Konditionale Typen sind nicht auf Typaliase beschränkt. Sie können sie fast überall verwenden, wo Typen benutzt werden können: in Typaliasen, Interfaces, Klassen, Parametertypen und generischen Standardangaben in Funktionen und Methoden.
Wie in den vorigen Beispielen gezeigt, lassen sich einfache Bedingungen in TypeScript auf vielerlei Arten ausdrücken. Hierzu gehören konditionale Typen, überladene Funktionssignaturen und abgebildete (mapped) Typen. Konditionale Typen können aber noch mehr. Der Grund ist, dass sie dem Distributivgesetz folgen (kennen Sie das noch aus dem Algebraunterricht?). Das heißt, bei einem konditionalen Typ entspricht der Ausdruck auf der rechten Seite den Angaben auf der linken Seite, wie in Tabelle 6-1 gezeigt.
Tabelle 6-1: Distribution bei konditionalen Typen
Dies ... |
entspricht |
string extends T ? A : B |
string extends T ? A : B |
(string | number) extends T ? A : B |
(string extends T ? A : B) | (number extends T ? A : B) |
(string | number | boolean) extends T ? A : B |
(string extends T ? A : B) | (number extends T ? A : B) | (boolean extends T ? A : B) |
Schon klar. Sie haben dieses Buch nicht gekauft, um Mathematik zu lernen – Sie wollen etwas über Typen erfahren. Werden wir also etwas konkreter. Angenommen, wir haben eine Funktion, die eine Variable des Typs T übernimmt. Sie wird zu einem Array des Typs T[] erhöht. Was passiert wohl, wenn wir einen Vereinigungstyp für T übergeben? Sehen Sie selbst:
type ToArray<T> = T[]
type A = ToArray<number> // number[]
type B = ToArray<number | string> // (number | string)[]
Und was passiert, wenn wir einen konditionalen Typ hinzufügen? (Beachten Sie, dass die Bedingung hier nichts tut, weil beide Zweige den gleichen Typ, T[], ergeben. Sie dient hier nur dem Zweck, T auf den Tupel zu verteilen.) Das könnte so aussehen:
type ToArray2<T> = T extends unknown ? T[] : T[]
type A = ToArray2<number> // number[]
type B = ToArray2<number | string> // number[] | string[]
Ist Ihnen etwas aufgefallen? Wenn Sie einen konditionalen Typ verwenden, verteilt TypeScript die Vereinigungstypen auf die Zweige der Bedingung. Das ist, als würden wir den konditionalen Typ auf die einzelnen Elemente der Vereinigungsmenge abbilden (Verzeihung, verteilen).
Und warum ist das wichtig? Weil Sie dadurch eine Reihe häufiger Operationen sicher ausdrücken können.
TypeScript besitzt die Operatoren & (um zu berechnen, was zwei Typen gemeinsam haben) und | (um die Vereinigung zweier Typen zu ermitteln). Als Beispiel erstellen wir Without<T, U>, das errechnet, welche Typen sich in T, aber nicht in U befinden.
type Without<T, U> = T extends U ? never : T
Without wird verwendet, wie hier gezeigt:
type A = Without<
boolean | number | string,
boolean
> // number | string
Am besten sehen wir uns Schritt für Schritt an, wie TypeScript diesen Typ berechnet:
type A = Without<boolean | number | string, boolean>
type A = Without<boolean, boolean>
| Without<number, boolean>
| Without<string, boolean>
type A = (boolean extends boolean ? never : boolean)
| (number extends boolean ? never : number)
| (string extends boolean ? never : string)
type A = never
| number
| string
type A = number | string
Ohne die Fähigkeit der Verteilung von konditionalen Typen lautete das Ergebnis jetzt never (wenn Sie nicht sicher sind, warum, gehen Sie die Schritte noch einmal in Ruhe durch).
Das letzte Merkmal konditionaler Typen ist die Fähigkeit, generische Typen als Teil einer Bedingung zu deklarieren. Bisher kennen wir nur eine Möglichkeit, generische Typparameter mithilfe spitzer Klammern (<T>) zu deklarieren. Konditionale Typen haben ihre eigene Syntax, um generische Typen »inline« zu deklarieren. Sie verwenden das Schlüsselwort infer.
Auch hierzu ein Beispiel. Wir deklarieren den konditionalen Typ ElementType, der den Typ eines Arrayelements ermittelt:
type ElementType<T> = T extends unknown[] ? T[number] : T
type A = ElementType<number[]> // number
Und jetzt noch einmal, aber unter Verwendung von infer:
type ElementType2<T> = T extends (infer U)[] ? U : T
type B = ElementType2<number[]> // number
In diesem einfachen Beispiel sind ElementType und ElementType2 gleichbedeutend. Wie Sie sehen, deklariert infer eine neue Typvariable namens U. Den Typ von U leitet TypeScript aus dem Kontext ab, basierend darauf, was für ein T Sie an ElementType2 übergeben haben.
Anstatt U gleich zu Beginn zusammen mit T zu deklarieren, haben wir es hier »inline« deklariert. Was wäre passiert, wenn wir U zu Beginn deklariert hätten?
type ElementUgly<T, U> = T extends U[] ? U : T
type C = ElementUgly<number[]> // Error TS2314: Generic type 'ElementUgly'
// requires 2 type argument(s).
Autsch! Da ElementUgly die generischen Typen T und U definiert, müssen wir bei der Instanziierung von ElementUgly auch beide Typen übergeben. Das ergibt aber keinen Sinn, denn es überlässt die Berechnung von U dem Aufrufer, obwohl Element Ugly den Typ eigentlich selbst berechnen sollte.
Zugegeben, dieses Beispiel war etwas albern, da wir bereits den Operator für schlüsselbasierten Zugriff ([]) verwenden, um herauszufinden, welchen Typ die Arrayelemente haben. Wie wäre es mit einem etwas komplizierteren Beispiel?
type SecondArg<F> = F extends (a: any, b: infer B) => any ? B : never
// Den Typ von Array.slice ermitteln
type F = typeof Array['prototype']['slice']
type A = SecondArg<F> // number | undefined
Das zweite Argument für [].slice lautet number | undefined. Und das wissen wir bereits bei der Kompilierung. Versuchen Sie das mal in Java.
Mit konditionalen Typen können Sie sehr mächtige Operationen auf Typebene durchführen. Aus diesem Grund enthält TypeScript bereits von Haus aus einige global verfügbare konditionale Typen:
Exclude<T, U>
Wie unser selbstgestrickter Without-Typ berechnet Exclude, welche Typen in T vorkommen, aber nicht in U:
type A = number | string
type B = string
type C = Exclude<A, B> // number
Extract<T, U>
Berechnet die Typen in T, die Sie an U zuweisen können:
type A = number | string
type B = string
type C = Extract<A, B> // string
NonNullable<T>
Berechnet eine Version von T, die null und undefined ausschließt:
type A = {a?: number | null}
type B = NonNullable<A['a']> // number
Berechnet den Rückgabetyp einer Funktion (Obacht: Für generische und überladene Funktionen funktioniert das nicht wie erwartet!):
type F = (a: number) => string
type R = ReturnType<F> // string
InstanceType<C>
Berechnet den Instanztyp eines Klassenkonstruktors:
type A = {new(): B}
type B = {b: number}
type I = InstanceType<A> // {b: number}
Manchmal haben Sie einfach keine Zeit, alles perfekt zu typisieren, und TypeScript soll einfach darauf vertrauen, dass Sie schon wissen, was Sie tun. Vielleicht ist eine Typdeklaration in einem von Ihnen genutzten Dritthersteller-Modul nicht korrekt, und Sie wollen Ihren Code testen, bevor Sie wieder zu strenger Typisierung wechseln,4 oder Sie erhalten Daten aus einer API und haben die Typdeklarationen noch nicht mit Apollo (https://www.apollographql.com/) regeneriert.
Glücklicherweise weiß TypeScript, dass wir Menschen sind, und bietet uns ein paar Notausgänge für Fälle, wenn wir etwas tun wollen, aber keine Zeit haben, TypeScript zu beweisen, dass es sicher ist.
Für alle Fälle: Benutzen Sie die folgenden TypeScript-Features so selten wie möglich. Wenn Sie merken, dass Sie ohne diese Merkmale nicht auskommen, machen Sie wahrscheinlich etwas falsch. |
Angenommen, Sie haben einen Typ B und es gilt A <: B <: C, dann können Sie dem Typechecker verbindlich mitteilen, dass B eigentlich ein A oder C ist.Dabei können Sie nur angeben, dass ein Typ ein Super- oder Subtyp seiner selbst ist, aber nicht behaupten, dass eine Zahl (number) eine Zeichenkette (string) ist, da diese Typen nicht verwandt sind.
TypeScript stellt zwei Schreibweisen für Typzusicherungen zu Verfügung:
function formatInput(input: string) {
// ...
}
function getUserInput(): string | number {
// ...
}
let input = getUserInput()
// Angeben, dass input ein String ist
formatInput(input as string)
// Das entspricht
formatInput(<string>input)
Anstelle der veralteten Schreibweise mit spitzen Klammern (<>) sollten Sie die as-Syntax verwenden, weil sie nicht verwechselt werden kann. Bei der Verwendung spitzer Klammern besteht die Gefahr, dass es zu Konflikten mit der TSX-Syntax kommt (siehe »TSX = JSX + TypeScript« auf Seite 204). Um dies für Ihre Codebasis zu erzwingen, können Sie die TSLint-Regel no-angle-bracket-type-assertion (http://bit.ly/2WEGGKe) verwenden. |
Manchmal sind zwei Typen nicht ausreichend verwandt, und Sie können nicht einfach behaupten, dass beide im Grunde gleich sind. Das lässt sich umgehen, indem Sie den Typ als any zusichern (aus »Zuweisbarkeit« auf Seite 123 wissen Sie, dass any auf alles zuweisbar ist). Danach sollten Sie sich ein paar Minuten Zeit nehmen und überlegen, was Sie da gerade getan haben:
function addToList(list: string[], item: string) {
// ...
}
addToList('this is really,' as any, 'really unsafe')
Typzusicherungen sind auf keinen Fall sicher. Daher sollten Sie sie nach Möglichkeit vermeiden.
Für den Sonderfall der nullwertfähigen Typen – d.h. T | null oder T | null | undefined – gibt es in TypeScript eine spezielle Schreibweise, mit der Sie zusichern können, dass ein Wert eines bestimmten Typs wirklich ein T ist und ganz bestimmt nicht null oder undefined. Das kann an verschiedenen Stellen vorkommen.
Vielleicht haben wir ein Framework geschrieben, das Dialoge in einer Web-App anzeigen oder verbergen soll. Jeder Dialog erhält eine einmalige ID, die wir als Referenz auf den DOM-Knoten des jeweiligen Dialogs verwenden. Sobald ein Dialog aus dem DOM entfernt wird, löschen wir seine ID, um anzuzeigen, dass er sich nicht mehr im DOM befindet:
type Dialog = {
id?: string
}
function closeDialog(dialog: Dialog) {
if (!dialog.id) {
return
}
setTimeout(() =>
removeFromDOM(
dialog,
document.getElementById(dialog.id) // Error TS2345: Argument of type
// 'string | undefined' is not assignable
// to parameter of type 'string'.
)
)
}
function removeFromDOM(dialog: Dialog, element: Element) {
element.parentNode.removeChild(element) // Error TS2531: Object is possibly
//'null'.
delete dialog.id
}
Das Problem könnte man durch eine Reihe von if (_ === null)-Tests umgehen. Grundsätzlich ist das der richtige Weg, um zu testen, ob etwas null ist oder nicht. Für Fälle, in denen es nicht klar ist, ob etwas den Typ null | undefined hat, besitzt TypeScript allerdings auch eine spezielle Syntax:
type Dialog = {
id?: string
}
function closeDialog(dialog: Dialog) {
if (!dialog.id) {
return
}
setTimeout(() =>
removeFromDOM(
dialog,
document.getElementById(dialog.id!)!
)
)
}
function removeFromDOM(dialog: Dialog, element: Element) {
element.parentNode!.removeChild(element)
delete dialog.id
}
Hier haben wir an verschiedenen Stellen den »Nicht-null-Zusicherungs«-Operator (!) benutzt, um TypeScript mitzuteilen, dass wir sicher sind, dass dialog.id, das Ergebnis unseres Aufrufs von document.getElementById und element.ParentNode tatsächlich definiert sind. Wurde einem Typ, der null oder undefined sein kann, der !-Operator nachgestellt, geht TypeScript davon aus, dass dieser Typ auf jeden Fall definiert ist. Das heißt, T | null | undefined wird zu T, number | string | null wird zu number | string etc.
Wenn Sie merken, dass Sie viele Nicht-null-Zusicherungen verwenden, ist das oft ein Zeichen dafür, dass Sie Ihren Code refaktorieren sollten. Wenn wir Dialog in die Vereinigungsmenge zweier Typen aufteilen, können wir beispielsweise auf eine Zusicherung verzichten:
type VisibleDialog = {id: string}
type DestroyedDialog = {}
type Dialog = VisibleDialog | DestroyedDialog
Danach können wir closeDialog so anpassen, dass es die Vereinigungsmenge nutzt:
function closeDialog(dialog: Dialog) {
if (!('id' in dialog)) {
return
}
setTimeout(() =>
removeFromDOM(
dialog,
document.getElementById(dialog.id)!
)
)
}
function removeFromDOM(dialog: VisibleDialog, element: Element) {
element.parentNode!.removeChild(element)
delete dialog.id
}
Nach der Überprüfung, ob dialog eine definierte id-Eigenschaft besitzt (und es sich also um einen VisibleDialog handelt), weiß TypeScript – selbst innerhalb der Pfeil-Funktion –, dass sich die Referenz auf dialog nicht verändert hat. Der dialog in der Pfeil-Funktion ist also der gleiche dialog wie außerhalb der Funktion, und die Verfeinerung wird tatsächlich berücksichtigt.
Für den Sonderfall einer Nicht-null-Zusicherung für die Überprüfung definitiver Zusicherungen (auf diese Weise stellt TypeScript sicher, dass eine Variable bei ihrer Verwendung auch wirklich einen Wert hat) besitzt TypeScript eine spezielle Syntax. Zum Beispiel:
let userId: string
userId.toUpperCase() // Error TS2454: Variable 'userId' is used
// before being assigned.
Mit dem Abfangen dieses Fehlers hat TypeScript uns einen besonders großen Gefallen getan. Wir haben die Variable userId zwar deklariert, aber vergessen, ihr vor der Umwandlung in Großbuchstaben einen Wert zuzuweisen. Hätte TypeScript das nicht bemerkt, wäre es hier zu einem Laufzeitfehler gekommen!
Was passiert aber, wenn unser Code eher so aussieht?
let userId: string
fetchUser()
userId.toUpperCase() // Error TS2454: Variable 'userId' is used
// before being assigned.
function fetchUser() {
userId = globalCache.get('userId')
}
Wir haben den besten Cache der Welt und erhalten in 100 % aller Fälle auch ein Ergebnis zurück. Nach dem Aufruf von fetchUser gibt es also garantiert einen Wert für userId. Das kann TypeScript aber weder wissen noch statistisch herausfinden. Also löst es den gleichen Fehler aus wie zuvor. Das können wir verhindern, indem wir zusichern, dass userId auf jeden Fall einen Wert besitzt, wenn wir die Variable auslesen (beachten Sie das Ausrufungszeichen):
let userId!: string
fetchUser()
userId.toUpperCase() // OK
userId = globalCache.get('userId')
}
Wie bei Typ- und Nicht-null-Zusicherungen gilt auch bei Zusicherungen einer definitiven Zuweisung: Wenn Sie diese Zusicherungen oft einsetzen, machen Sie sehr wahrscheinlich etwas falsch.
Wir sind in diesem Buch schon recht weit gekommen. Wenn ich Sie um drei Uhr morgens wachrüttle und brülle: »IST DAS TYPSYSTEM VON TYPESCRIPT STRUKTURELL ODER NOMINAL?!«, würden Sie, ohne zu zögern, zurückbrüllen: »TYPESCRIPT IST NATÜRLICH STRUKTURELL! UND JETZT RAUS HIER, ODER ICH RUFE DIE POLIZEI!!« Das wäre eine angemessene Reaktion, wenn ich früh morgens bei Ihnen einbreche, um Sie mit TypeScript-Fragen zu malträtieren.
Lassen wir die rechtliche Situation einmal außen vor, gibt es tatsächlich Fälle, in denen nominale Typen nützlich sein können. Angenommen, Ihre Applikation enthält mehrere ID-Typen, die für verschiedene Arten von Objekten in Ihrem System stehen:
type CompanyID = string
type OrderID = string
type UserID = string
type ID = CompanyID | OrderID | UserID
Der Wert des Typs UserID könnte ein einfacher Hashwert sein, der etwa so aussieht: "d21b1dbf". Selbst wenn Sie das Alias UserID verwenden, bleibt es hinter den Kulissen ein einfacher string. Eine Funktion, die eine UserID übernimmt, könnte beispielsweise so aussehen:
function queryForUser(id: UserID) {
// ...
}
Dies ist ein gutes Beispiel für selbst dokumentierenden Code. Er zeigt anderen Entwicklern in Ihrem Team sofort, welche Art von ID hier übergeben werden soll. Da UserID aber nur ein Alias für string ist, verhindert dieser Ansatz Fehler nur scheinbar. Ein Entwickler könnte versehentlich die falsche Art von ID übergeben, und das Typsystem kann nichts daran ändern.
let id: CompanyID = 'b4843361'
queryForUser(id) // OK (!!!)
Und genau hier können nominale Typen tatsächlich hilfreich sein.5 TypeScript unterstützt nominale Typen nicht von Haus aus. Wir können sie aber mit dem sogenannten Type Branding (wörtlich: »Typen mit Brandzeichen versehen«, d.h. sie eindeutig kennzeichnen) simulieren. Die Verwendung von Type Branding in TypeScript erfordert etwas Vorbereitung und ist nicht so einfach wie in Sprachen, die nominale Typaliase von Haus aus unterstützen. Dennoch kann das Type Branding Ihr Programm deutlich schneller machen.
Je nach Applikation und Größe Ihres Entwicklerteams kann dieses Vorgehen hilfreich sein oder nicht. (Je größer das Team, desto eher hilft diese Technik bei der Fehlervermeidung.) |
Beginnen Sie mit der Erstellung eines künstlichen Type Brands für alle Ihre nominalen Typen:
type CompanyID = string & {readonly brand: unique symbol}
type OrderID = string & {readonly brand: unique symbol}
type UserID = string & {readonly brand: unique symbol}
type ID = CompanyID | OrderID | UserID
Eine Schnittmenge aus string und {readonly brand: unique symbol} ist natürlich Unsinn. Ich verwende diesen Typ, weil es unmöglich ist, ihn auf natürliche Weise zu konstruieren. Die einzige Möglichkeit, einen Wert dieses Typs zu erstellen, besteht in der Verwendung einer Zusicherung (assertion). Das ist eine der wichtigen Eigenschaften von mit Type Branding erstellten Typen. Es ist schwer, an ihrer Stelle versehentlich einen falschen Typ zu verwenden. Ich habe hier unique symbol als »Brandzeichen« verwendet, da es einer der beiden tatsächlich nominalen Typen in TypeScript ist (der andere ist enum). Ich habe die Schnittmenge aus diesem »Brandzeichen« und string gebildet, um zusichern zu können, dass string tatsächlich ein »branded type« ist.
Jetzt brauchen wir eine Möglichkeit, Werte der Typen CompanyID, OrderID und UserID zu erzeugen. Hierfür verwenden wir das Companion-Objektmuster (siehe »Das Companion-Objektmuster« auf Seite 141). Für jeden »branded type« erstellen wir einen Konstruktor. Dabei verwenden wir eine Typzusicherung, um einen Wert für jeden unserer unsinnigen Typen zu konstruieren:
function CompanyID(id: string) {
return id as CompanyID
}
function OrderID(id: string) {
return id as OrderID
}
function UserID(id: string) {
return id as UserID
}
Und so sieht die Verwendung dieser Typen aus:
function queryForUser(id: UserID) {
// ...
}
let companyId = CompanyID('8a6076cf')
let orderId = OrderID('9994acc1')
let userId = UserID('d21b1dbf')
queryForUser(userId) // OK
queryForUser(companyId) // Error TS2345: Argument of type 'CompanyID' is not
// assignable to parameter of type 'UserID'.
Das Schöne an diesem Ansatz ist, dass er zur Laufzeit kaum zusätzliche Ressourcen benötigt: nur ein Funktionsaufruf pro ID-Erstellung, der sehr wahrscheinlich von Ihrer JavaScript-VM ohnehin per Inlining wegoptimiert wird. Zur Laufzeit ist jede ID einfach ein string – das »branding« existiert also nur während der Kompilierung.
Für die meisten Programme ist dieser Aufwand vermutlich zu groß. Bei umfangreichen Applikationen und bei der Arbeit mit Typen, die leicht verwechselt werden können, z .B. verschiedene Arten von IDs, können »branded types« deutlich zur Sicherheit beitragen.