Kapitel 4. Generika
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Bisher bestand unser Hauptziel darin, die inhärente Flexibilität von JavaScript zu nutzen und einen Weg zu finden, sie durch das Typsystem zu formalisieren. Wir haben statische Typen für eine dynamisch typisierte Sprache hinzugefügt, um die Absicht zu kommunizieren, Werkzeuge zu erhalten und Fehler zu finden, bevor sieauftreten.
Manche Teile von JavaScript interessieren sich allerdings nicht für statische Typen. Eine isKeyAvailableInObject
Funktion sollte zum Beispiel nur prüfen, ob ein Schlüssel in einem Objekt vorhanden ist; sie muss nichts über die konkreten Typen wissen. Um eine solche Funktion richtig zu formalisieren, können wir das strukturelle Typsystem von TypeScript verwenden und entweder einen sehr breiten Typ zum Preis von Informationen oder einen sehr strengen Typ zum Preis vonFlexibilität beschreiben.
Aber wir wollen nicht jeden Preis zahlen. Wir wollen sowohl Flexibilität als auch Informationen. Die Generics in TypeScript sind genau der Königsweg, den wir brauchen. Wir können komplexe Beziehungen beschreiben und die Struktur von Daten formalisieren, die noch nicht definiert wurden.
Generics und die damit verbundenen gemappten Typen, Typ-Maps, Typ-Modifikatoren und Hilfstypen öffnen die Tür zum Metatyping, bei dem wir neue Typen auf der Grundlage alter Typen erstellen und die Beziehungen zwischen den Typen intakt halten können, während die neu generierten Typen unseren ursprünglichen Code auf mögliche Fehler überprüfen.
Dies ist der Einstieg in fortgeschrittene TypeScript-Konzepte. Aber keine Angst, es wird keine Drachen geben, außer wir definieren sie.
4.1 Verallgemeinerung von Funktionssignaturen
Diskussion
Du schreibst eine Anwendung, die mehrere Sprachdateien (zum Beispiel Untertitel) in einem Objekt speichert. Die Schlüssel sind die Sprachcodes, und die Werte sind URLs. Du lädst Sprachdateien, indem du sie über einen Sprachcode auswählst, der von einer API oder einer Benutzeroberfläche wie string
stammt. Um sicherzustellen, dass der Sprachcode korrekt und gültig ist, fügst du eine isLanguageAvailable
Funktion hinzu, die eine in
Prüfung durchführt und den richtigen Typ mithilfe eines Typprädikats festlegt:
type
Languages
=
{
de
:
URL
;
en
:
URL
;
pt
:
URL
;
es
:
URL
;
fr
:
URL
;
ja
:
URL
;
};
function
isLanguageAvailable
(
collection
:
Languages
,
lang
:
string
)
:
lang
is
keyof
Languages
{
return
lang
in
collection
;
}
function
loadLanguage
(
collection
:
Languages
,
lang
:
string
)
{
if
(
isLanguageAvailable
(
collection
,
lang
))
{
// lang is keyof Languages
collection
[
lang
];
// access ok!
}
}
Gleiche Anwendung, anderes Szenario, ganz andere Datei. Du lädst Mediendaten in ein HTML-Element: entweder Audio, Video oder eine Kombination mit bestimmten Animationen in einem canvas
Element. Alle Elemente sind bereits in der Anwendung vorhanden, aber du musst das richtige Element auf der Grundlage von Eingaben aus einer API auswählen. Auch hier kommt die Auswahl als string
, und du schreibst eine isElementAllowed
Funktion, um sicherzustellen, dass die Eingabe tatsächlich ein gültiger Schlüssel deiner AllowedElements
Sammlung ist:
type
AllowedElements
=
{
video
:
HTMLVideoElement
;
audio
:
HTMLAudioElement
;
canvas
:
HTMLCanvasElement
;
};
function
isElementAllowed
(
collection
:
AllowedElements
,
elem
:
string
)
:
elem
is
keyof
AllowedElements
{
return
elem
in
collection
;
}
function
selectElement
(
collection
:
AllowedElements
,
elem
:
string
)
{
if
(
isElementAllowed
(
collection
,
elem
))
{
// elem is keyof AllowedElements
collection
[
elem
];
// access ok
}
}
Man muss nicht allzu genau hinschauen, um zu sehen, dass sich beide Szenarien sehr ähnlich sind. Vor allem die Typschutzfunktionen fallen ins Auge. Wenn wir alle Typinformationen weglassen und die Namen angleichen, sind sie identisch:
function
isAvailable
(
obj
,
key
)
{
return
key
in
obj
;
}
Die beiden existieren wegen der Typinformationen, die wir erhalten. Nicht wegen der Eingabeparameter, sondern wegen der Typprädikate. In beiden Fällen können wir durch die Behauptung eines bestimmten keyof
Typs mehr über die Eingabeparameter erfahren.
Das Problem ist, dass beide Eingabetypen für die Sammlung völlig unterschiedlich sind und keine Überschneidungen haben. Mit Ausnahme des leeren Objekts, für das wir nicht so viele wertvolle Informationen erhalten, wenn wir einen keyof
Typ erstellen. keyof {}
ist eigentlich never
.
Aber es gibt hier einige Typinformationen, die wir verallgemeinern können. Wir wissen, dass der erste Eingabeparameter ein Objekt ist. Und der zweite Parameter ist ein Eigenschaftsschlüssel. Wenn diese Prüfung den Wert true
ergibt, wissen wir, dass der erste Parameter ein Schlüssel des zweiten Parameters ist.
Um diese Funktion zu verallgemeinern, können wir isAvailable
einen generischen Typ-Parameter namens Obj
hinzufügen, der in spitzen Klammern steht. Dies ist ein Platzhalter für einen tatsächlichen Typ, der ersetzt wird, sobald isAvailable
verwendet wird. Wir können diesen generischen Typparameter wie AllowedElements
oder Languages
verwenden und ein Typprädikat hinzufügen. Da Obj
durch jeden Typ ersetzt werden kann, muss key
alle möglichen Eigenschaftsschlüssel enthalten -string
, symbol
und number
:
function
isAvailable
<
Obj
>
(
obj
:
Obj
,
key
:
string
|
number
|
symbol
)
:
key
is
keyof
Obj
{
return
key
in
obj
;
}
function
loadLanguage
(
collection
:
Languages
,
lang
:
string
)
{
if
(
isAvailable
(
collection
,
lang
))
{
// lang is keyof Languages
collection
[
lang
];
// access ok!
}
}
function
selectElement
(
collection
:
AllowedElements
,
elem
:
string
)
{
if
(
isAvailable
(
collection
,
elem
))
{
// elem is keyof AllowedElements
collection
[
elem
];
// access ok
}
}
Und da hast du es: eine Funktion, die in beiden Szenarien funktioniert, egal, welche Typen wir durch Obj
ersetzen. Genau wie JavaScript funktioniert! Wir haben immer noch dieselbe Funktionalität und erhalten die richtigen Typinformationen. Der Indexzugriff wird sicher, ohne dass die Flexibilität darunter leidet.
Und das Beste daran? Wir können isAvailable
genauso verwenden, wie wir ein nicht typisiertes JavaScript-Pendant verwenden würden. Das liegt daran, dass TypeScript die Typen für generische Typparameter durch die Verwendung herleitet. Und das hat ein paar nette Nebeneffekte. Mehr dazu erfährst du in Rezept 4.3.
4.3 Unbekanntes loswerden
Lösung
Verwende generische Typparameter, wenn du schließlich zum eigentlichen Typ kommst; siehe Rezept 2.2 zur Entscheidung zwischen any
und unknown
.
Diskussion
Wenn wir Generika verwenden, könnten sie wie ein Ersatz für any
und unknown
erscheinen. Nimm eine identity
Funktion - ihre einzige Aufgabe ist es, den alsEingabeparameter übergebenen Wert zurückzugeben:
function
identity
(
value
:
any
)
:
any
{
return
value
;
}
let
a
=
identity
(
"Hello!"
);
let
b
=
identity
(
false
);
let
c
=
identity
(
2
);
Sie nimmt Werte jeden Typs an und der Rückgabetyp kann ebenfalls alles sein. Wir können die gleiche Funktion mit unknown
schreiben, wenn wir sicher auf Eigenschaften zugreifen wollen:
function
identity
(
value
:
unknown
)
:
unknown
{
return
value
;
}
let
a
=
identity
(
"Hello!"
);
let
b
=
identity
(
false
);
let
c
=
identity
(
2
);
Wir können sogar any
und unknown
miteinander kombinieren, aber das Ergebnis ist immer dasselbe: Die Typinformation geht verloren. Der Typ des Rückgabewerts ist das, was wir als solchen definieren.
Schreiben wir nun die gleiche Funktion mit Generika anstelle von any
oder unknown
. Die Typ-Annotationen besagen, dass der generische Typ auch der Rückgabetyp ist:
function
identity
<
T
>
(
t
:
T
)
:
T
{
return
t
;
}
Wir können diese Funktion verwenden, um einen beliebigen Wert zu übergeben und zu sehen, welchen Typ TypeScript daraus folgert:
let
a
=
identity
(
"Hello!"
);
// a is string
let
b
=
identity
(
2000
);
// b is number
let
c
=
identity
({
a
:
2
});
// c is { a: number }
Die Zuweisung zu einer Bindung mit const
anstelle von let
führt zu etwas anderen Ergebnissen:
const
a
=
identity
(
"Hello!"
);
// a is "Hello!"
const
b
=
identity
(
2000
);
// b is 2000
const
c
=
identity
({
a
:
2
});
// c is { a: number }
Bei primitiven Typen ersetzt TypeScript den generischen Typparameter durch den tatsächlichen Typ. Das können wir in fortgeschrittenen Szenarien gut gebrauchen.
Mit den Generics von TypeScript ist es auch möglich, den generischen Typparameter zu annotieren:
const
a
=
identity
<
string
>
(
"Hello!"
);
// a is string
const
b
=
identity
<
number
>
(
2000
);
// b is number
const
c
=
identity
<
{
a
:
2
}
>
({
a
:
2
});
// c is { a: 2 }
Wenn dich dieses Verhalten an die in Rezept 3.4 beschriebene Annotation und Inferenz erinnert, hast du völlig recht. Es ist sehr ähnlich, aber mit generischen Typparametern in Funktionen.
Wenn wir Generics ohne Einschränkungen verwenden, können wir Funktionen schreiben, die mit Werten beliebigen Typs arbeiten. Im Inneren verhalten sie sich wie unknown
, d.h. wir können Type Guards verwenden, um den Typ einzuschränken. Der größte Unterschied ist, dass wir, sobald wir die Funktion verwenden, unsere Generics durch echte Typen ersetzen und dabei keinerlei Informationen über die Typisierung verlieren.
So können wir unsere Typen etwas klarer formulieren, als wenn wir einfach alles zulassen. Diese pairs
Funktion nimmt zwei Argumente entgegen und erstellt ein Tupel:
function
pairs
(
a
:
unknown
,
b
:
unknown
)
:
[
unknown
,
unknown
]
{
return
[
a
,
b
];
}
const
a
=
pairs
(
1
,
"1"
);
// [unknown, unknown]
Mit generischen Typparametern erhalten wir einen schönen Tupeltyp:
function
pairs
<
T
,
U
>
(
a
:
T
,
b
:
U
)
:
[
T
,
U
]
{
return
[
a
,
b
];
}
const
b
=
pairs
(
1
,
"1"
);
// [number, string]
Mit demselben generischen Typparameter können wir sicherstellen, dass wir nur Tupel erhalten, bei denen jedes Element vom selben Typ ist:
function
pairs
<
T
>
(
a
:
T
,
b
:
T
)
:
[
T
,
T
]
{
return
[
a
,
b
];
}
const
c
=
pairs
(
1
,
"1"
);
// ^
// Argument of type 'string' is not assignable to parameter of type 'number'
Solltest du also überall Generika verwenden? Nicht unbedingt. Dieses Kapitel enthält viele Lösungen, bei denen es darauf ankommt, die richtigen Typinformationen zur richtigen Zeit zu erhalten. Wenn du dich mit einer größeren Menge von Werten zufrieden gibst und dich darauf verlassen kannst, dass die Untertypen kompatibel sind, brauchst du Generics überhaupt nicht zu verwenden. Wenn du any
und unknown
in deinem Code hast, überlege, ob du den tatsächlichen Typ irgendwann brauchst. Es könnte hilfreich sein, stattdessen einen generischen Typparameter hinzuzufügen.
4.4 Die generische Instanziierung verstehen
Lösung
Erinnere dich daran, dass Werte eines generischen Typs - explizit und implizit - durch eine Vielzahl von Subtypen substituiert werden können. Schreibe subtypenfreundlichen Code.
Diskussion
Du erstellst eine Filterlogik für deine Anwendung. Du hast verschiedene Filterregeln, die du mit "and" | "or"
Kombinatoren kombinieren kannst. Du kannst auch reguläre Filterregeln mit dem Ergebnis von kombinatorischen Filtern verketten. Du erstellst deine Typen auf der Grundlage diesesVerhaltens:
type
FilterRule
=
{
field
:
string
;
operator
:
string
;
value
:
any
;
};
type
CombinatorialFilter
=
{
combinator
:
"and"
|
"or"
;
rules
:
FilterRule
[];
};
type
ChainedFilter
=
{
rules
:
(
CombinatorialFilter
|
FilterRule
)[];
};
type
Filter
=
CombinatorialFilter
|
ChainedFilter
;
Jetzt willst du eine reset
Funktion schreiben, die auf der Grundlage eines bereits vorhandenen Filters alle Regeln zurücksetzt. Du verwendest Type Guards, um zwischen CombinatorialFilter
und ChainedFilter
zu unterscheiden:
function
reset
(
filter
:
Filter
)
:
Filter
{
if
(
"combinator"
in
filter
)
{
// filter is CombinatorialFilter
return
{
combinator
:
"and"
,
rules
:
[]
};
}
// filter is ChainedFilter
return
{
rules
:
[]
};
}
const
filter
:
CombinatorialFilter
=
{
rules
:
[],
combinator
:
"or"
};
const
resetFilter
=
reset
(
filter
);
// resetFilter is Filter
Das Verhalten ist das, was du willst, aber der Rückgabetyp von reset
ist zu breit. Wenn wir einen CombinatorialFilter
übergeben, sollten wir sicher sein, dass der Rückgabefilter auch ein CombinatorialFilter
ist. Hier ist es der Union-Typ, genau wie unsere Funktionssignatur angibt. Du willst aber sicherstellen, dass du, wenn du einen Filter eines bestimmten Typs übergibst, auch den gleichen Rückgabetyp erhältst. Also ersetzt du den breiten Union-Typ durch einen generischen Typ-Parameter, der auf Filter
beschränkt ist. Der Rückgabetyp funktioniert wie vorgesehen, aber die Implementierung deiner Funktion wirft Fehler:
function
reset
<
F
extends
Filter
>
(
filter
:
F
)
:
F
{
if
(
"combinator"
in
filter
)
{
return
{
combinator
:
"and"
,
rules
:
[]
};
// ^ '{ combinator: "and"; rules: never[]; }' is assignable to
// the constraint of type 'F', but 'F' could be instantiated
// with a different subtype of constraint 'Filter'.
}
return
{
rules
:
[]
};
//^ '{ rules: never[]; }' is assignable to the constraint of type 'F',
// but 'F' could be instantiated with a different subtype of
// constraint 'Filter'.
}
const
resetFilter
=
reset
(
filter
);
// resetFilter is CombinatorialFilter
Während du zwischen zwei Teilen einer Union unterscheiden willst, denkt TypeScript breiter. Es weiß, dass du ein Objekt übergeben kannst, das strukturell mit Filter
kompatibel ist, aber mehr Eigenschaften hat und daher ein Subtyp ist.
Das bedeutet, dass du reset
mit F
aufrufen kannst, das auf einen Subtyp instanziiert ist, und dein Programm würde alle überflüssigen Eigenschaften überschreiben. Das ist falsch, und TypeScript sagt dir das:
const
onDemandFilter
=
reset
({
combinator
:
"and"
,
rules
:
[],
evaluated
:
true
,
result
:
false
,
});
/* filter is {
combinator: "and";
rules: never[];
evaluated: boolean;
result: boolean;
}; */
Überwinde dies, indem du subtypenfreundlichen Code schreibst. Klone das Eingabeobjekt (immer noch vom Typ F
), setze die Eigenschaften, die geändert werden müssen, entsprechend und gib etwas zurück, das immer noch vom Typ F
ist:
function
reset
<
F
extends
Filter
>
(
filter
:
F
)
:
F
{
const
result
=
{
...
filter
};
// result is F
result
.
rules
=
[];
if
(
"combinator"
in
result
)
{
result
.
combinator
=
"and"
;
}
return
result
;
}
const
resetFilter
=
reset
(
filter
);
// resetFilter is CombinatorialFilter
Generische Typen können einer von vielen in einer Union sein, aber sie können noch viel mehr sein. TypeScripts strukturelles Typensystem ermöglicht es dir, mit einer Vielzahl von Untertypen zu arbeiten, und dein Code muss das widerspiegeln.
Hier ist ein anderes Szenario, aber mit einem ähnlichen Ergebnis. Du möchtest eine Datenstruktur in Form eines Baumes erstellen und schreibst einen rekursiven Typ, der alle Baumelemente speichert. Dieser Typ kannsubtypisiert werden, also schreibst du eine createRootItem
Funktion mit einem generischen Typparameter, da du sie mit dem richtigen Subtyp instanziieren willst:
type
TreeItem
=
{
id
:
string
;
children
:
TreeItem
[];
collapsed
?:
boolean
;
};
function
createRootItem
<
T
extends
TreeItem
>
()
:
T
{
return
{
id
:
"root"
,
children
:
[],
};
// '{ id: string; children: never[]; }' is assignable to the constraint
// of type 'T', but 'T' could be instantiated with a different subtype
// of constraint 'TreeItem'.(2322)
}
const
root
=
createRootItem
();
// root is TreeItem
Wir erhalten eine ähnliche Fehlermeldung wie zuvor, da wir nicht sagen können, dass der Rückgabewert mit allen Untertypen kompatibel ist. Um dieses Problem zu lösen, musst du den generischen Typ loswerden! Wir wissen, wie der Rückgabetyp aussehen wird - es ist ein TreeItem
:
function
createRootItem
()
:
TreeItem
{
return
{
id
:
"root"
,
children
:
[],
};
}
Die einfachsten Lösungen sind oft die besseren. Aber jetzt willst du deine Software erweitern, indem du Kinder vom Typ oder Untertyp TreeItem
an eine neu erstellte Wurzel anhängen kannst. Wir fügen noch keine Generika hinzu und sind etwas unzufrieden:
function
attachToRoot
(
children
:
TreeItem
[])
:
TreeItem
{
return
{
id
:
"root"
,
children
,
};
}
const
root
=
attachToRoot
([]);
// TreeItem
root
ist vom Typ TreeItem
, aber wir verlieren alle Informationen über die untergeordneten Kinder. Selbst wenn wir einen generischen Typ-Parameter nur für die Kinder hinzufügen, der auf TreeItem
beschränkt ist, behalten wir diese Information nicht:
function
attachToRoot
<
T
extends
TreeItem
>
(
children
:
T
[])
:
TreeItem
{
return
{
id
:
"root"
,
children
,
};
}
const
root
=
attachToRoot
([
{
id
:
"child"
,
children
:
[],
collapsed
:
false
,
marked
:
true
,
},
]);
// root is TreeItem
Wenn wir anfangen, einen generischen Typ als Rückgabetyp hinzuzufügen, stoßen wir auf die gleichen Probleme wie zuvor. Um dieses Problem zu lösen, müssen wir den Stammtyp von den Kindtypen trennen, indem wir TreeItem
zu einem generischen Typ machen, bei dem wir Children
zu einem Untertyp von TreeItem
machen können.
Da wir zirkuläre Verweise vermeiden wollen, müssen wir Children
auf einen Standard BaseTreeItem
setzen, damit wir TreeItem
sowohl als Einschränkung für Children
als auch für attachToRoot
verwenden können:
type
BaseTreeItem
=
{
id
:
string
;
children
:
BaseTreeItem
[];
};
type
TreeItem
<
Children
extends
TreeItem
=
BaseTreeItem
>
=
{
id
:
string
;
children
:
Children
[];
collapsed
?:
boolean
;
};
function
attachToRoot
<
T
extends
TreeItem
>
(
children
:
T
[])
:
TreeItem
<
T
>
{
return
{
id
:
"root"
,
children
,
};
}
const
root
=
attachToRoot
([
{
id
:
"child"
,
children
:
[],
collapsed
:
false
,
marked
:
true
,
},
]);
/*
root is TreeItem<{
id: string;
children: never[];
collapsed: false;
marked: boolean;
}>
*/
Auch hier schreiben wir subtypenfreundlich und behandeln unsere Eingabeparameter als ihre eigenen, anstatt Annahmen zu treffen.
4.5 Neue Objekttypen generieren
Lösung
Verwende generische gemappte Typen, um neue Objekttypen auf der Grundlage des ursprünglichen Typs zu erstellen.
Diskussion
Kehren wir zu dem Spielzeugladen aus Rezept 3.1 zurück. Dank der Vereinigungsarten, Schnittmengenarten und diskriminierten Vereinigungsarten konnten wir unsere Daten recht gut modellieren:
type
ToyBase
=
{
name
:
string
;
description
:
string
;
minimumAge
:
number
;
};
type
BoardGame
=
ToyBase
&
{
kind
:
"boardgame"
;
players
:
number
;
};
type
Puzzle
=
ToyBase
&
{
kind
:
"puzzle"
;
pieces
:
number
;
};
type
Doll
=
ToyBase
&
{
kind
:
"doll"
;
material
:
"plush"
|
"plastic"
;
};
type
Toy
=
Doll
|
Puzzle
|
BoardGame
;
Irgendwo in unserem Code müssen wir alle Spielzeuge aus unserem Modell in einer Datenstruktur gruppieren, die durch einen Typ namens GroupedToys
beschrieben werden kann. GroupedToys
hat eine Eigenschaft für jede Kategorie (oder "kind"
) und ein Toy
Array als Wert. Eine groupToys
Funktion nimmt eine unsortierte Liste von Spielzeugen und gruppiert sie nach Art:
type
GroupedToys
=
{
boardgame
:
Toy
[];
puzzle
:
Toy
[];
doll
:
Toy
[];
};
function
groupToys
(
toys
:
Toy
[])
:
GroupedToys
{
const
groups
:
GroupedToys
=
{
boardgame
:
[],
puzzle
:
[],
doll
:
[],
};
for
(
let
toy
of
toys
)
{
groups
[
toy
.
kind
].
push
(
toy
);
}
return
groups
;
}
In diesem Code gibt es bereits einige Feinheiten. Erstens verwenden wir eine explizite Typ-Annotation, wenn wir groups
deklarieren. Damit stellen wir sicher, dass wir keine Kategorie vergessen. Da die Schlüssel von GroupedToys
mit der Vereinigung der Typen von "kind"
in Toy
übereinstimmen, können wir den Indexzugriff auf groups
einfach über toy.kind
vornehmen.
Monate und Sprints vergehen, und wir müssen unser Modell wieder anfassen. Der Spielzeugladen verkauft jetzt originale oder vielleicht auch alternative Anbieter von ineinander greifenden Spielzeugbausteinen. Wir schließen den neuen Typ Bricks
an unser Toy
Modell an:
type
Bricks
=
ToyBase
&
{
kind
:
"bricks"
,
pieces
:
number
;
brand
:
string
;
}
type
Toy
=
Doll
|
Puzzle
|
BoardGame
|
Bricks
;
Da groupToys
auch mit Bricks
umgehen muss, bekommen wir einen netten Fehler, weil GroupedToys
keine Ahnung von einer "bricks"
Art hat:
function
groupToys
(
toys
:
Toy
[])
:
GroupedToys
{
const
groups
:
GroupedToys
=
{
boardgame
:
[],
puzzle
:
[],
doll
:
[],
};
for
(
let
toy
of
toys
)
{
groups
[
toy
.
kind
].
push
(
toy
);
// ^- Element implicitly has an 'any' type because expression
// of type '"boardgame" | "puzzle" | "doll" | "bricks"' can't
// be used to index type 'GroupedToys'.
// Property 'bricks' does not exist on type 'GroupedToys'.(7053)
}
return
groups
;
}
Das ist ein gewünschtes Verhalten in TypeScript: zu wissen, wenn Typen nicht mehr übereinstimmen. Das sollte unsere Aufmerksamkeit erregen. Lass uns GroupedToys
und groupToys
ein Update geben:
type
GroupedToys
=
{
boardgame
:
Toy
[];
puzzle
:
Toy
[];
doll
:
Toy
[];
bricks
:
Toy
[];
};
function
groupToys
(
toys
:
Toy
[])
:
GroupedToys
{
const
groups
:
GroupedToys
=
{
boardgame
:
[],
puzzle
:
[],
doll
:
[],
bricks
:
[],
};
for
(
let
toy
of
toys
)
{
groups
[
toy
.
kind
].
push
(
toy
);
}
return
groups
;
}
Es gibt eine lästige Sache: Die Aufgabe, Spielzeug zu gruppieren, ist immer dieselbe. Egal, wie sehr sich unser Modell ändert, wir werden immer nach Art auswählen und in ein Array schieben. Bei jeder Änderung müssten wir groups
pflegen, aber wenn wir die Art und Weise, wie wir über Gruppen denken, ändern, können wir sie für Veränderungen optimieren. Zuerst ändern wir den Typ GroupedToys
, um optionale Eigenschaften zu erhalten. Zweitens initialisieren wir jede Gruppe mit einem leeren Array, wenn noch keine Initialisierung stattgefunden hat:
type
GroupedToys
=
{
boardgame
?:
Toy
[];
puzzle
?:
Toy
[];
doll
?:
Toy
[];
bricks
?:
Toy
[];
};
function
groupToys
(
toys
:
Toy
[])
:
GroupedToys
{
const
groups
:
GroupedToys
=
{};
for
(
let
toy
of
toys
)
{
// Initialize when not available
groups
[
toy
.
kind
]
=
groups
[
toy
.
kind
]
??
[];
groups
[
toy
.
kind
]
?
.
push
(
toy
);
}
return
groups
;
}
Wir brauchen groupToys
nicht mehr zu pflegen. Das Einzige, was gewartet werden muss, ist der Typ GroupedToys
. Wenn wir uns GroupedToys
genauer ansehen, sehen wir, dass es eine implizite Beziehung zu Toy
gibt. Jeder Eigenschaftsschlüssel ist Teil von Toy["kind"]
. Machen wir diese Beziehung explizit. Mit einem gemappten Typ erstellen wir einen neuen Objekttyp, der auf jedem Typ in Toy["kind"]
basiert.
Toy["kind"]
ist eine Vereinigung von String-Literalen: "boardgame" | "puzzle" | "doll" | "bricks"
. Da wir eine sehr reduzierte Menge von Strings haben, wird jedes Element dieser Vereinigung als eigener Eigenschaftsschlüssel verwendet. Lass das einen Moment auf dich wirken: Wir können einen Typ als Eigenschaftsschlüssel für einen neu erzeugten Typ verwenden. Jede Eigenschaft hat einen optionalen Typmodifikator und zeigt auf eine Toy[]
:
type
GroupedToys
=
{
[
k
in
Toy
[
"kind"
]]
?:
Toy
[];
};
Fantastisch! Jedes Mal, wenn wir Toy
ändern, ändern wir sofort Toy[]
. Unser Code muss überhaupt nicht geändert werden; wir können immer noch nach Art gruppieren, wie wir es vorher getan haben.
Das ist ein Muster, das wir verallgemeinern können. Lass uns einen Group
Typ erstellen, der eine Sammlung aufnimmt und sie nach einem bestimmten Selektor gruppiert. Wir wollen einen generischen Typ mit zwei Typparametern erstellen:
-
Die
Collection
kann alles sein. -
Die
Selector
, ein Schlüssel vonCollection
, damit sie die entsprechenden Eigenschaften erstellen kann.
Unser erster Versuch wäre, das zu nehmen, was wir in GroupedToys
hatten, und die konkreten Typen durch Typparameter zu ersetzen. Das schafft, was wir brauchen, führt aber auch zu einem Fehler:
// How to use it
type
GroupedToys
=
Group
<
Toy
,
"kind"
>
;
type
Group
<
Collection
,
Selector
extends
keyof
Collection
>
=
{
[
x
in
Collection
[
Selector
]]
?:
Collection
[];
// ^ Type 'Collection[Selector]' is not assignable
// to type 'string | number | symbol'.
// Type 'Collection[keyof Collection]' is not
// assignable to type 'string | number | symbol'.
// Type 'Collection[string] | Collection[number]
// | Collection[symbol]' is not assignable to
// type 'string | number | symbol'.
// Type 'Collection[string]' is not assignable to
// type 'string | number | symbol'.(2322)
};
TypeScript warnt uns, dass Collection[string] | Collection[number] | Collection[symbol]
zu allem führen kann, nicht nur zu Dingen, die als Schlüssel verwendet werden können. Das stimmt, und darauf müssen wir uns vorbereiten. Wir haben zwei Möglichkeiten.
Verwende zunächst eine Typbeschränkung auf Collection
, die auf Record<string, any>
zeigt. Record
ist ein Hilfstyp, der ein neues Objekt erzeugt, bei dem der erste Parameter alle Schlüssel und der zweite Parameter die Typen angibt:
// This type is built-in!
type
Record
<
K
extends
string
|
number
|
symbol
,
T
>
=
{
[
P
in
K
]
:
T
;
};
Dadurch wird Collection
zu einem Wildcard-Objekt, das die Typüberprüfung von Groups
deaktiviert. Das ist in Ordnung, denn wenn etwas ein unbrauchbarer Typ für einen Eigenschaftsschlüssel ist, wird TypeScript es sowieso wegwerfen. Die endgültige Group
hat also zwei eingeschränkte Typparameter:
type
Group
<
Collection
extends
Record
<
string
,
any
>
,
Selector
extends
keyof
Collection
>
=
{
[
x
in
Collection
[
Selector
]]
:
Collection
[];
};
Die zweite Möglichkeit ist, für jeden Schlüssel zu prüfen, ob er ein gültiger String-Schlüssel ist. Wir können einen bedingten Typ verwenden, um festzustellen, ob Collection[Selector]
tatsächlich ein gültiger Typ für einen Schlüssel ist. Andernfalls würden wir diesen Typ entfernen, indem wir never
wählen. Bedingte Typen sind etwas ganz Besonderes, und wir werden uns in Rezept 5.4 ausführlich damit beschäftigen:
type
Group
<
Collection
,
Selector
extends
keyof
Collection
>
=
{
[
k
in
Collection
[
Selector
]
extends
string
?
Collection
[
Selector
]
:
never
]
?:
Collection
[];
};
Beachte, dass wir den optionalen Typ-Modifikator entfernt haben. Wir tun dies, weil es nicht die Aufgabe der Gruppierung ist, Schlüssel optional zu machen. Dafür haben wir einen anderen Typ: Partial<T>
Ein anderer gemappter Typ, der jede Eigenschaft in einem Objekttyp optional macht:
// This type is built-in!
type
Partial
<
T
>
=
{
[
P
in
keyof
T
]
?:
T
[
P
]
};
Unabhängig davon, welchen Group
-Helper du erstellst, kannst du jetzt ein GroupedToys
-Objekt erstellen, indem du TypeScript mitteilst, dass du ein Partial
eines Group
eines Toys
von "kind"
haben möchtest (und alles in optionale Eigenschaften umwandelst):
type
GroupedToys
=
Partial
<
Group
<
Toy
,
"kind"
>>
;
4.6 Ändern von Objekten mit Assertion Signatures
Lösung
Verwende Assertion Signatures, um Typen unabhängig von if
und switch
Anweisungen zu ändern.
Diskussion
JavaScript ist eine sehr flexible Sprache. Dank der dynamischen Typisierung kannst du Objekte während der Laufzeit ändern und neue Eigenschaften hinzufügen. Und Entwickler nutzen das. Es gibt Situationen, in denen du z. B. eine Sammlung von Elementen durchläufst und bestimmte Eigenschaften bestätigen musst. Du speicherst dann eine checked
Eigenschaft und setzt sie auf true
, nur damit du weißt, dass du eine bestimmte Marke überschritten hast:
function
check
(
person
:
any
)
{
person
.
checked
=
true
;
}
const
person
=
{
name
:
"Stefan"
,
age
:
27
,
};
check
(
person
);
// person now has the checked property
person
.
checked
;
// this is true!
Du willst dieses Verhalten im Typsystem widerspiegeln, denn sonst müsstest du ständig prüfen, ob bestimmte Eigenschaften in einem Objekt vorhanden sind, auch wenn du sicher sein kannst, dass sie existieren.
Eine Möglichkeit zu behaupten, dass bestimmte Eigenschaften existieren, sind, nun ja, Typ-Behauptungen. Wir sagen, dass diese Eigenschaft zu einem bestimmten Zeitpunkt einen anderen Typ hat:
(
person
as
typeof
person
&
{
checked
:
boolean
}).
checked
=
true
;
Gut, aber du müsstest diese Typ-Assertion immer wieder durchführen, da sie den ursprünglichen Typ von person
nicht verändern. Eine andere Möglichkeit, um zu behaupten, dass bestimmte Eigenschaften vorhanden sind, ist die Erstellung von Typ-Prädikaten, wie in Rezept 3.5 gezeigt:
function
check
<
T
>
(
obj
:
T
)
:
obj
is
T
&
{
checked
:
true
}
{
(
obj
as
T
&
{
checked
:
boolean
}).
checked
=
true
;
return
true
;
}
const
person
=
{
name
:
"Stefan"
,
age
:
27
,
};
if
(
check
(
person
))
{
person
.
checked
;
// checked is true!
}
Diese Situation ist jedoch etwas anders, wodurch sich die Funktion check
ungeschickt anfühlt: Du musst eine zusätzliche Bedingung erfüllen und true
in der Prädikatsfunktion zurückgeben. Das fühlt sich nicht richtig an.
Zum Glück gibt es in TypeScript eine weitere Technik, die wir in solchen Situationen nutzen können: Assertion-Signaturen. Assertion-Signaturen können den Typ eines Wertes im Kontrollfluss ändern, ohne dass dafür Konditionale erforderlich sind. Sie wurden für die Node.js-Funktion assert
modelliert, die eine Bedingung annimmt und einen Fehler auslöst, wenn sie nicht wahr ist. Das bedeutet, dass du nach dem Aufruf von assert
möglicherweise mehr Informationen hast als vorher. Wenn du zum Beispiel assert
aufrufst und prüfst, ob ein Wert den Typ string
hat, weißt du, dass der Wert nach dieser assert
Funktion string
sein sollte:
function
assert
(
condition
:
any
,
msg
?:
string
)
:
asserts
condition
{
if
(
!
condition
)
{
throw
new
Error
(
msg
);
}
}
function
yell
(
str
:
any
)
{
assert
(
typeof
str
===
"string"
);
// str is string
return
str
.
toUpperCase
();
}
Bitte beachte, dass die Funktion einen Kurzschluss verursacht, wenn die Bedingung falsch ist. Sie wirft einen Fehler, den never
Fall. Wenn diese Funktion erfolgreich ist, kannst du die Bedingung wirklich bestätigen.
Obwohl die Assertion-Signaturen für die Node.js-Assert-Funktion modelliert wurden, kannst du jede beliebige Art von Assertion verwenden. Du kannst zum Beispiel eine Funktion haben, die einen beliebigen Wert für eine Addition annimmt, aber du behauptest, dass die Werte number
sein müssen, um fortzufahren:
function
assertNumber
(
val
:
any
)
:
asserts
val
is
number
{
if
(
typeof
val
!==
"number"
)
{
throw
Error
(
"value is not a number"
);
}
}
function
add
(
x
:
unknown
,
y
:
unknown
)
:
number
{
assertNumber
(
x
);
// x is number
assertNumber
(
y
);
// y is number
return
x
+
y
;
}
Alle Beispiele, die du zu Assertion-Signaturen findest, basieren auf Assertions und schließen sich mit Fehlern kurz. Aber wir können die gleiche Technik verwenden, um TypeScript mitzuteilen, dass mehr Eigenschaften verfügbar sind. Wir schreiben eine Funktion, die der check
in der Prädikatsfunktion zuvor sehr ähnlich ist, aber dieses Mal müssen wir nicht true
zurückgeben. Wir setzen die Eigenschaft, und da Objekte in JavaScript als Wert übergeben werden, können wir behaupten, dass nach dem Aufruf dieser Funktion alles, was wir übergeben, eine Eigenschaft checked
hat, die true
ist:
function
check
<
T
>
(
obj
:
T
)
:
asserts
obj
is
T
&
{
checked
:
true
}
{
(
obj
as
T
&
{
checked
:
boolean
}).
checked
=
true
;
}
const
person
=
{
name
:
"Stefan"
,
age
:
27
,
};
check
(
person
);
Und damit können wir den Typ eines Wertes im Handumdrehen ändern. Das ist eine wenig bekannte Technik, die dir sehr helfen kann.
4.7 Typen mit Typkarten abbilden
Lösung
Speichere alle Subtypen in einer Type Map, erweitere sie mit Indexzugriff und verwende gemappte Typen wie Partial<T>
.
Diskussion
Factory-Funktionen sind ideal, wenn du Varianten komplexer Objekte auf der Grundlage einiger grundlegender Informationen erstellen willst. Ein Szenario, das du vielleicht aus Browser-JavaScript kennst, ist die Erstellung von Elementen. Die Funktion document.createElement
nimmt den Tag-Namen eines Elements entgegen und du erhältst ein Objekt, in dem du alle notwendigenEigenschaften ändern kannst.
Du möchtest diese Erstellung mit einer netten Fabrikfunktion aufpeppen, die du createElement
nennst. Sie nimmt nicht nur den Tag-Namen des Elements, sondern erstellt auch eine Liste von Eigenschaften, damit du nicht jede Eigenschaft einzeln festlegen musst:
// Using create Element
// a is HTMLAnchorElement
const
a
=
createElement
(
"a"
,
{
href
:
"https://fettblog.eu"
});
// b is HTMLVideoElement
const
b
=
createElement
(
"video"
,
{
src
:
"/movie.mp4"
,
autoplay
:
true
});
// c is HTMLElement
const
c
=
createElement
(
"my-element"
);
Du willst dafür gute Typen erstellen, also musst du auf zwei Dinge achten:
-
Achte darauf, dass du nur gültige HTML-Elemente erstellst.
-
Biete einen Typ an, der eine Teilmenge der Eigenschaften eines HTML-Elements akzeptiert.
Kümmern wir uns zuerst um die gültigen HTML-Elemente. Es gibt etwa 140 mögliche HTML-Elemente, was eine Menge ist. Jedes dieser Elemente hat einen Tag-Namen, der als String dargestellt werden kann, und ein entsprechendes Prototyp-Objekt im DOM. Mit der dom lib in deiner tsconfig.json hat TypeScript Informationen über diese Prototyp-Objekte in Form von Typen. Und du kannst alle 140 Elementnamen herausfinden.
Eine gute Möglichkeit, um eine Zuordnung zwischen Element-Tag-Namen und Prototyp-Objekten herzustellen, ist die Verwendung einer Type Map. Eine Type Map ist eine Technik, bei der du einen Typ-Alias oder eine Schnittstelle nimmst und die Schlüssel auf die jeweiligen Typvarianten zeigen lässt. Die richtige Typvariante erhältst du dann über den Index-Zugriff auf einen String-Literal-Typ:
type
AllElements
=
{
a
:
HTMLAnchorElement
;
div
:
HTMLDivElement
;
video
:
HTMLVideoElement
;
//... and ~140 more!
};
// HTMLAnchorElement
type
A
=
AllElements
[
"a"
];
Es sieht aus wie der Zugriff auf die Eigenschaften eines JavaScript-Objekts mit Hilfe des Indexzugriffs, aber erinnere dich daran, dass wir immer noch auf Typenebene arbeiten. Das bedeutet, dass der Indexzugriff breit gefächert sein kann:
type
AllElements
=
{
a
:
HTMLAnchorElement
;
div
:
HTMLDivElement
;
video
:
HTMLVideoElement
;
//... and ~140 more!
};
// HTMLAnchorElement | HTMLDivELement
type
AandDiv
=
AllElements
[
"a"
|
"div"
];
Verwenden wir diese Karte, um die Funktion createElement
zu schreiben. Wir verwenden einen generischen Typparameter, der auf alle Schlüssel von AllElements
beschränkt ist, so dass wir nur gültige HTML-Elemente übergeben können:
function
createElement
<
T
extends
keyof
AllElements
>
(
tag
:
T
)
:
AllElements
[
T
]
{
return
document
.
createElement
(
tag
as
string
)
as
AllElements
[
T
];
}
// a is HTMLAnchorElement
const
a
=
createElement
(
"a"
);
Verwende hier Generika, um ein String-Literal an einen Literal-Typ zu binden, den wir verwenden können, um die richtige HTML-Element-Variante aus der Type Map zu indizieren. Beachte auch, dass die Verwendung von document.createElement
zwei Typbestätigungen erfordert. Eine macht die Menge breiter (T
bis string
) und eine macht die Menge schmaler (HTMLElement
bis AllElements[T]
). Beide Behauptungen zeigen, dass wir es mit einer API zu tun haben, die außerhalb unserer Kontrolle liegt, wie in Rezept 3.9 beschrieben. Wir werden uns später mit den Behauptungen befassen.
Jetzt wollen wir die Möglichkeit bieten, zusätzliche Eigenschaften für diese HTML-Elemente zu übergeben, ein href
auf ein HTMLAnchorElement
zu setzen und so weiter. Alle Eigenschaften sind bereits in den jeweiligen HTMLElement
Varianten enthalten, aber sie sind obligatorisch und nicht optional. Wir können alle Eigenschaften mit dem eingebauten Typ Partial<T>
optional machen. Dabei handelt es sich um einen gemappten Typ, der alle Eigenschaften eines bestimmten Typs aufnimmt und einen Typmodifikator hinzufügt:
type
Partial
<
T
>
=
{
[
P
in
keyof
T
]
?:
T
[
P
]
};
Wir erweitern unsere Funktion um ein optionales Argument props
, das ein Partial
des indizierten Elements von AllElements
ist. Auf diese Weise wissen wir, dass wir, wenn wir ein "a"
übergeben, nur Eigenschaften setzen können, die in HTMLAnchorElement
verfügbar sind:
function
createElement
<
T
extends
keyof
AllElements
>
(
tag
:
T
,
props
?:
Partial
<
AllElements
[
T
]
>
)
:
AllElements
[
T
]
{
const
elem
=
document
.
createElement
(
tag
as
string
)
as
AllElements
[
T
];
return
Object
.
assign
(
elem
,
props
);
}
const
a
=
createElement
(
"a"
,
{
href
:
"https://fettblog.eu"
});
const
x
=
createElement
(
"a"
,
{
src
:
"https://fettblog.eu"
});
// ^--
// Argument of type '{ src: string; }' is not assignable to parameter
// of type 'Partial<HTMLAnchorElement>'.
// Object literal may only specify known properties, and 'src' does not
// exist in type 'Partial<HTMLAnchorElement>'.(2345)
Fantastisch! Jetzt liegt es an dir, alle 140 HTML-Elemente herauszufinden. Oder auch nicht. Jemand hat sich bereits die Arbeit gemacht und HTMLElementTagNameMap
in lib.dom.ts eingefügt. Also lass uns stattdessen das hier verwenden:
function
createElement
<
T
extends
keyof
HTMLElementTagNameMap
>
(
tag
:
T
,
props
?:
Partial
<
HTMLElementTagNameMap
[
T
]
>
)
:
HTMLElementTagNameMap
[
T
]
{
const
elem
=
document
.
createElement
(
tag
);
return
Object
.
assign
(
elem
,
props
);
}
Dies ist auch die Schnittstelle, die von document.createElement
verwendet wird, sodass es keine Reibung zwischen deiner Factory-Funktion und der eingebauten Funktion gibt. Es sind keine zusätzlichen Assertions notwendig.
Es gibt nur eine Einschränkung. Du bist auf die 140 Elemente beschränkt, die von HTMLElementTagNameMap
bereitgestellt werden. Was ist, wenn du SVG-Elemente oder Webkomponenten erstellen willst, die vollständig angepasste Elementnamen haben können? Dann ist deine Factory-Funktion plötzlichzu eingeschränkt.
Um mehr zuzulassen - wie es document.createElement
tut - müssten wir alle möglichen Zeichenketten wieder in den Mix aufnehmen. HTMLElementTagNameMap
ist eine Schnittstelle. Daher können wir die Schnittstelle mit Hilfe der Deklarationszusammenführung um eine indizierte Signatur erweitern, bei der wir alle verbleibenden Zeichenketten auf HTMLUnknownElement
abbilden:
interface
HTMLElementTagNameMap
{
[
x
:
string
]
:
HTMLUnknownElement
;
};
function
createElement
<
T
extends
keyof
HTMLElementTagNameMap
>
(
tag
:
T
,
props
?:
Partial
<
HTMLElementTagNameMap
[
T
]
>
)
:
HTMLElementTagNameMap
[
T
]
{
const
elem
=
document
.
createElement
(
tag
);
return
Object
.
assign
(
elem
,
props
);
}
// a is HTMLAnchorElement
const
a
=
createElement
(
"a"
,
{
href
:
"https://fettblog.eu"
});
// b is HTMLUnknownElement
const
b
=
createElement
(
"my-element"
);
Jetzt haben wir alles, was wir wollen:
-
Eine großartige Fabrikfunktion zum Erstellen von typisierten HTML-Elementen
-
Die Möglichkeit, Elementeigenschaften mit nur einem Konfigurationsobjekt zu setzen
-
Die Flexibilität, mehr Elemente als definiert zu erstellen
Letzteres ist toll, aber was ist, wenn du nur Webkomponenten zulassen willst? Für Webkomponenten gibt es eine Konvention: Sie müssen einen Bindestrich in ihrem Tag-Namen haben. Wir können dies mit einem gemappten Typ auf einem String-Template-Literal-Typ modellieren. Alles über String-Templating-Literal-Typen erfährst du in Kapitel 6.
Im Moment musst du nur wissen, dass wir eine Reihe von Zeichenketten erstellen, deren Muster eine beliebige Zeichenkette gefolgt von einem Bindestrich und einer beliebigen Zeichenkette ist. Das reicht aus, um sicherzustellen, dass wir nur korrekte Elementnamen übergeben.
Gemappte Typen funktionieren nur mit Typ-Aliasen, nicht mit Schnittstellendeklarationen, also müssen wir wieder einen AllElements
Typ definieren:
type
AllElements
=
HTMLElementTagNameMap
&
{
[
x
in
`
${
string
}
-
${
string
}
`
]
:
HTMLElement
;
};
function
createElement
<
T
extends
keyof
AllElements
>
(
tag
:
T
,
props
?:
Partial
<
AllElements
[
T
]
>
)
:
AllElements
[
T
]
{
const
elem
=
document
.
createElement
(
tag
as
string
)
as
AllElements
[
T
];
return
Object
.
assign
(
elem
,
props
);
}
const
a
=
createElement
(
"a"
,
{
href
:
"https://fettblog.eu"
});
// OK
const
b
=
createElement
(
"my-element"
);
// OK
const
c
=
createElement
(
"thisWillError"
);
// ^
// Argument of type '"thisWillError"' is not
// assignable to parameter of type '`${string}-${string}`
// | keyof HTMLElementTagNameMap'.(2345)
Fantastisch. Mit dem Typ AllElements
bekommen wir auch Typ-Assertions zurückbekommen, die wir nicht so sehr mögen. In diesem Fall können wir statt einer Assertion auch eine Funktionsüberladung verwenden, indem wir zwei Deklarationen definieren: eine für unsere Benutzer und eine für uns, um die Funktion zu implementieren. Mehr über diese Technik der Funktionsüberladung erfährst du in den Rezepten 2.6 und 12.7:
function
createElement
<
T
extends
keyof
AllElements
>
(
tag
:
T
,
props
?:
Partial
<
AllElements
[
T
]
>
)
:
AllElements
[
T
];
function
createElement
(
tag
:
string
,
props
?:
Partial
<
HTMLElement
>
)
:
HTMLElement
{
const
elem
=
document
.
createElement
(
tag
);
return
Object
.
assign
(
elem
,
props
);
}
Wir sind bereit. Wir haben eine Type Map mit gemappten Typen und Indexsignaturen definiert und generische Typparameter verwendet, um unsere Absichten deutlich zu machen. Eine großartige Kombination aus mehreren Werkzeugen in unserem TypeScript-Werkzeuggürtel.
4.8 ThisType verwenden, um dies in Objekten zu definieren
Lösung
Verwende die eingebaute generische ThisType<T>
, um die richtige this
zu definieren.
Diskussion
Frameworks wie VueJS setzen stark auf Factory-Funktionen, bei denen du ein umfassendes Konfigurationsobjekt übergibst, um Anfangsdaten, berechnete Eigenschaften und Methoden für jede Instanz zu definieren. Du möchtest ein ähnliches Verhalten für die Komponenten deiner App schaffen. Die Idee ist, ein Konfigurationsobjekt mit drei Eigenschaften bereitzustellen:
- Eine
data
Funktion -
Der Rückgabewert sind die Anfangsdaten für die Instanz. Du solltest in dieser Funktion keinen Zugriff auf andere Eigenschaften des Konfigurationsobjekts haben.
- Eine
computed
Eigenschaft -
Dies gilt für berechnete Eigenschaften, die auf den ursprünglichen Daten basieren. Berechnete Eigenschaften werden mit Funktionen deklariert. Sie können genau wienormale Eigenschaften auf die Ausgangsdaten zugreifen.
- Eine
methods
Eigenschaft -
Methoden können aufgerufen werden und können sowohl auf berechnete Eigenschaften als auch auf die ursprünglichen Daten zugreifen. Wenn Methoden auf berechnete Eigenschaften zugreifen, greifen sie darauf zu wie auf normale Eigenschaften: Sie müssen die Funktion nicht aufrufen.
Betrachtet man das verwendete Konfigurationsobjekt, gibt es drei verschiedene Möglichkeiten, this
zu interpretieren. In data
hat this
überhaupt keine Eigenschaften. In computed
kann jede Funktion über this
auf den Rückgabewert von data
zugreifen, so als wäre er Teil des eigenen Objekts. In methods
kann jede Methode über this
auf die berechneten Eigenschaften und data
zugreifen:
const
instance
=
create
({
data
()
{
return
{
firstName
:
"Stefan"
,
lastName
:
"Baumgartner"
,
};
},
computed
:
{
fullName
()
{
// has access to the return object of data
return
this
.
firstName
+
" "
+
this
.
lastName
;
},
},
methods
:
{
hi
()
{
// use computed properties just like normal properties
alert
(
this
.
fullName
.
toLowerCase
());
},
},
});
Dieses Verhalten ist besonders, aber nicht ungewöhnlich. Und bei so einem Verhalten wollen wir uns auf jeden Fall auf gute Typen verlassen.
Hinweis
In dieser Lektion werden wir uns nur auf die Typen konzentrieren, nicht auf die eigentliche Implementierung, da dies den Rahmen dieses Kapitels sprengen würde.
Lass uns Typen für jede Eigenschaft erstellen. Wir definieren einen Typ Options
, den wir Schritt für Schritt verfeinern werden. Zuerst die Funktion data
. data
kann benutzerdefiniert sein, deshalb wollen wir data
mit einem generischen Typparameter angeben. Die Daten, nach denen wir suchen, werden durch den Rückgabetyp der Funktion data
festgelegt:
type
Options
<
Data
>
=
{
data
(
this
:
{})
?:
Data
;
};
Sobald wir also einen tatsächlichen Rückgabewert in der Funktion data
angeben, wird der Platzhalter Data
durch den Typ des echten Objekts ersetzt. Beachte, dass wir auch this
definieren, um auf das leere Objekt zu zeigen, was bedeutet, dass wir keinen Zugriff auf andere Eigenschaften des Konfigurationsobjekts erhalten.
Als nächstes definieren wir computed
. computed
ist ein Objekt von Funktionen. Wir fügen einen weiteren generischen Typparameter namens Computed
hinzu und lassen den Wert von Computed
durch die Verwendung typisieren. Hier ändert sich this
in alle Eigenschaften von Data
. Da wir this
nicht wie in der Funktion data
setzen können, können wir den eingebauten Hilfstyp ThisType
verwenden und ihn auf den generischen Typparameter Data
setzen:
type
Options
<
Data
,
Computed
>
=
{
data
(
this
:
{})
?:
Data
;
computed
?:
Computed
&
ThisType
<
Data
>
;
};
So können wir z.B. auf this.firstName
zugreifen, wie im vorherigen Beispiel. Zu guter Letzt wollen wir methods
angeben. methods
ist wieder etwas Besonderes, da du über this
nicht nur Zugriff auf Data
erhältst, sondern auch auf alle Methoden und auf alle berechneten Eigenschaften als Eigenschaften.
Computed
enthält alle berechneten Eigenschaften als Funktionen. Wir bräuchten allerdings ihren Wert - genauer gesagt, ihren Rückgabewert. Wenn wir über den Eigenschaftszugriff auf fullName
zugreifen, erwarten wir, dass es eine string
ist.
Dazu erstellen wir einen Hilfstyp namens MapFnToProp
. Er nimmt einen Typ, der ein Objekt von Funktionen ist, und bildet ihn auf die Typen der Rückgabewerte ab. Der eingebaute Hilfstyp ReturnType
ist perfekt für dieses Szenario:
// An object of functions ...
type
FnObj
=
Record
<
string
,
()
=>
any
>
;
// ... to an object of return types
type
MapFnToProp
<
FunctionObj
extends
FnObj
>
=
{
[
K
in
keyof
FunctionObj
]
:
ReturnType
<
FunctionObj
[
K
]
>
;
};
Wir können MapFnToProp
verwenden, um ThisType
für einen neu hinzugefügten generischen Typparameter namens Methods
zu setzen. Wir fügen auch Data
und Methods
zu dem Mix hinzu. Um den Parameter des generischen Typs Computed
an MapFnToProp
zu übergeben, muss er an FnObj
gebunden werden, die gleiche Bindung wie der erste Parameter FunctionObj
in MapFnToProp
:
type
Options
<
Data
,
Computed
extends
FnObj
,
Methods
>
=
{
data
(
this
:
{})
?:
Data
;
computed
?:
Computed
&
ThisType
<
Data
>
;
methods
?:
Methods
&
ThisType
<
Data
&
MapFnToProp
<
Computed
>
&
Methods
>
;
};
Und das ist der Typ! Wir nehmen alle generischen Typeneigenschaften und fügen sie der Funktion create
factory hinzu:
declare
function
create
<
Data
,
Computed
extends
FnObj
,
Methods
>
(
options
:
Options
<
Data
,
Computed
,
Methods
>
)
:
any
;
Durch die Verwendung werden alle generischen Typparameter ersetzt. Und so wie Options
getippt ist, erhalten wir alle notwendigen Autovervollständigungen, um sicherzustellen, dass wir nicht in Schwierigkeiten geraten, wie in Abbildung 4-1 zu sehen ist.
Dieses Beispiel zeigt auf wunderbare Weise, wie TypeScript verwendet werden kann, um ausgefeilte APIs zu schreiben, bei denen eine Menge Objektmanipulationen unter stattfinden.1
4.9 Hinzufügen von Const-Kontext zu generischen Typparametern
Lösung
Füge einen const
Modifikator vor deinen generischen Typ-Parameter, um die übergebenenWerte im const-Kontext zu halten.
Diskussion
Single-Page-Application (SPA)-Frameworks neigen dazu, viele Browserfunktionen in JavaScript neu zu implementieren. Funktionen wie die History-API machen es zum Beispiel möglich, das normale Navigationsverhalten, mit dem SPA-Frameworks zwischen Seiten wechseln, ohne dass die Seite neu geladen werden muss, außer Kraft zu setzen, indem der Inhalt der Seite ausgetauscht und die URL im Browser geändert wird.
Stell dir vor, du arbeitest an einem minimalistischen SPA-Framework, das einen sogenannten Router verwendet, um zwischen den Seiten zu navigieren. Seiten werden als Komponenten definiert, und eine ComponentConstructor
Schnittstelle weiß, wie neue Elemente auf deiner Website instanziiert und gerendert werden können:
interface
ComponentConstructor
{
new
()
:
Component
;
}
interface
Component
{
render
()
:
HTMLElement
;
}
Der Router sollte eine Liste von Komponenten und zugehörigen Pfaden annehmen, die als string
gespeichert sind. Wenn du einen Router mit der Funktion router
erstellst, sollte er ein Objekt zurückgeben, mit dem du navigate
den gewünschten Pfad festlegen kannst:
type
Route
=
{
path
:
string
;
component
:
ComponentConstructor
;
};
function
router
(
routes
:
Route
[])
{
return
{
navigate
(
path
:
string
)
{
// ...
},
};
}
Wie die eigentliche Navigation implementiert ist, interessiert uns im Moment nicht; stattdessen wollen wir uns auf die Typisierung der Funktionsschnittstelle konzentrieren.
Der Router funktioniert wie vorgesehen; er nimmt ein Array von Route
Objekten und gibt ein Objekt mit einer navigate
Funktion zurück, mit der wir die Navigation von einer URL zur anderen auslösen können und die neue Komponente gerendert wird:
const
rtr
=
router
([
{
path
:
"/"
,
component
:
Main
,
},
{
path
:
"/about"
,
component
:
About
,
},
])
rtr
.
navigate
(
"/faq"
);
Was du sofort siehst, ist, dass die Typen viel zu weit gefasst sind. Wenn wir die Navigation zu allen string
erlauben, hält uns nichts davon ab, falsche Routen zu benutzen, die ins Leere führen. Wir müssten eine Art von Fehlerbehandlung für Informationen implementieren, die bereits fertig und verfügbar sind. Warum sie also nicht nutzen?
Unsere erste Idee wäre, den konkreten Typ durch einen generischen Typparameter zu ersetzen. Die Art und Weise, wie TypeScript mit der generischen Substitution umgeht, ist, dass TypeScript bei einem literalen Typ einen entsprechenden Subtyp verwendet. Die Einführung von T
für Route
und die Verwendung von T["path"]
anstelle von string
kommt dem nahe, was wir erreichen wollen:
function
router
<
T
extends
Route
>
(
routes
:
T
[])
{
return
{
navigate
(
path
:
T
[
"path"
])
{
// ...
},
};
}
In der Theorie sollte das funktionieren. Wenn wir uns daran erinnern, was TypeScript in diesem Fall mit literalen, primitiven Typen macht, würden wir erwarten, dass der Wert auf den literalen Typ eingegrenzt wird:
function
getPath
<
T
extends
string
>
(
route
:
T
)
:
T
{
return
route
;
}
const
path
=
getPath
(
"/"
);
// "/"
Mehr dazu kannst du in Rezept 4.3 lesen. Ein wichtiges Detail ist, dass path
im vorherigen Beispiel in einem const-Kontext steht, weil der zurückgegebene Wert unveränderlich ist.
Das einzige Problem ist, dass wir mit Objekten und Arrays arbeiten, und TypeScript neigt dazu, die Typen in Objekten und Arrays zu erweitern, um die Veränderbarkeit von Werten zu ermöglichen. Wenn wir uns ein ähnliches Beispiel ansehen, aber mit einem verschachtelten Objekt, sehen wir, dass TypeScript stattdessen den allgemeineren Typ nimmt:
type
Routes
=
{
paths
:
string
[];
};
function
getPaths
<
T
extends
Routes
>
(
routes
:
T
)
:
T
[
"paths"
]
{
return
routes
.
paths
;
}
const
paths
=
getPaths
({
paths
:
[
"/"
,
"/about"
]
});
// string[]
Bei Objekten bezieht sich der const-Kontext für paths
nur auf die Bindung der Variablen, nicht auf ihren Inhalt. Dadurch gehen einige der Informationen verloren, die wir für die korrekte Eingabe von navigate
benötigen.
Eine Möglichkeit, diese Einschränkung zu umgehen, ist die manuelle Anwendung von const context, bei der wir den Eingabeparameter readonly
umdefinieren müssen:
function
router
<
T
extends
Route
>
(
routes
:
readonly
T
[])
{
return
{
navigate
(
path
:
T
[
"path"
])
{
history
.
pushState
({},
""
,
path
);
},
};
}
const
rtr
=
router
([
{
path
:
"/"
,
component
:
Main
,
},
{
path
:
"/about"
,
component
:
About
,
},
]
as
const
);
rtr
.
navigate
(
"/about"
);
Das funktioniert, setzt aber auch voraus, dass wir beim Kodieren ein sehr wichtiges Detail nicht vergessen. Und sich aktiv an Umgehungen zu erinnern, ist immer ein Rezept für eine Katastrophe.
Glücklicherweise ermöglicht es TypeScript, const-Kontext von generischen Typparametern anzufordern. Anstatt ihn auf den Wert anzuwenden, ersetzen wir den Parameter des generischen Typs durch einen konkreten Wert , aber im const-Kontext, indem wir den Modifikator const
zum Parameter des generischen Typs hinzufügen:
function
router
<
const
T
extends
Route
>
(
routes
:
T
[])
{
return
{
navigate
(
path
:
T
[
"path"
])
{
// tbd
},
};
}
Wir können dann unseren Router wie gewohnt verwenden und erhalten sogar eine Autovervollständigung für mögliche Pfade:
const
rtr
=
router
([
{
path
:
"/"
,
component
:
Main
,
},
{
path
:
"/about"
,
component
:
About
,
},
])
rtr
.
navigate
(
"/about"
);
Noch besser: Wir bekommen richtige Fehler, wenn wir etwas Falsches eingeben:
const
rtr
=
router
([
{
path
:
"/"
,
component
:
Main
,
},
{
path
:
"/about"
,
component
:
About
,
},
])
rtr
.
navigate
(
"/faq"
);
// ^
// Argument of type '"/faq"' is not assignable to
// parameter of type '"/" | "/about"'.(2345)
Das Schöne daran: Es ist alles in der API der Funktion versteckt. Was wir erwarten, wird klarer, die Schnittstelle verrät uns die Einschränkungen, und wir müssen nichts mehr tun, wenn wir router
verwenden, um Typsicherheit zu gewährleisten.
1 Vielen Dank an die Macher von Type Challenges für dieses schöne Beispiel.
Get TypeScript Kochbuch now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.