Kapitel 4. Typ Design
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Wenn du mir deine Flussdiagramme zeigst und deine Tabellen versteckst, werde ich weiterhin verwirrt sein. Zeig mir deine Tabellen, und ich brauche deine Flussdiagramme normalerweise nicht; sie werden offensichtlich sein.
Fred Brooks, Der Monat des Mythos Mensch
Die Sprache in Fred Brooks' Zitat ist veraltet, aber die Aussage ist nach wie vor richtig: Code ist schwer zu verstehen, wenn man die Daten oder Datentypen, mit denen er arbeitet, nicht sehen kann. Das ist einer der großen Vorteile eines Typensystems: Indem du Typen ausschreibst, machst du sie für die Leser deines Codes sichtbar. Und das macht deinen Code verständlich.
In anderen Kapiteln geht es um die Grundlagen von TypeScript-Typen: wie man sie verwendet, wie man sie herleitet und wie man Deklarationen mit ihnen schreibt. In diesem Kapitel geht es um das Design der Typen selbst. Die Beispiele in diesem Kapitel sind alle mit Blick auf TypeScript geschrieben, aber die meisten Ideen sind auch allgemeiner anwendbar.
Wenn du deine Typen gut schreibst, dann werden mit etwas Glück auch deine Flussdiagramme eindeutig sein.
Punkt 28: Typen bevorzugen, die immer gültige Zustände darstellen
Wenn du deine Typen gut entwirfst, sollte dein Code einfach zu schreiben sein. Wenn du deine Typen jedoch schlecht entwirfst, kann dich keine noch so clevere Dokumentation retten. Dein Code wird verwirrend und fehleranfällig sein.
Ein Schlüssel zu effektivem Typendesign ist die Erstellung von Typen, die nur einen gültigen Zustand darstellen können. In diesem Artikel gehen wir ein paar Beispiele durch, wie das schiefgehen kann, und zeigen dir, wie du sie beheben kannst.
Angenommen, du baust eine Webanwendung, mit der du eine Seite auswählen, den Inhalt dieser Seite laden und dann anzeigen kannst. Du könntest den Status wie folgt schreiben:
interface
State
{
pageText
:string
;
isLoading
:boolean
;
error?
:string
;
}
Wenn du deinen Code zum Rendern der Seite schreibst, musst du alle diese Felder berücksichtigen:
function
renderPage
(
state
:State
)
{
if
(
state
.
error
)
{
return
`Error! Unable to load
${
currentPage
}
:
${
state
.
error
}
`
;
}
else
if
(
state
.
isLoading
)
{
return
`Loading
${
currentPage
}
...`
;
}
return
`<h1>
${
currentPage
}
</h1>
\
n
${
state
.
pageText
}
`
;
}
Aber ist das richtig? Was ist, wenn isLoading
und error
beide eingestellt sind? Was würde das bedeuten? Ist es besser, die Ladenachricht oder die Fehlermeldung anzuzeigen? Das ist schwer zu sagen! Es sind nicht genug Informationen verfügbar.
Oder was, wenn du eine changePage
Funktion schreibst? Hier ist ein Versuch:
async
function
changePage
(
state
:State
,
newPage
:string
)
{
state
.
isLoading
=
true
;
try
{
const
response
=
await
fetch
(
getUrlForPage
(
newPage
));
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Unable to load
${
newPage
}
:
${
response
.
statusText
}
`
);
}
const
text
=
await
response
.
text
();
state
.
isLoading
=
false
;
state
.
pageText
=
text
;
}
catch
(
e
)
{
state
.
error
=
''
+
e
;
}
}
Es gibt viele Probleme damit! Hier sind ein paar:
-
Wir haben vergessen,
state.isLoading
im Fehlerfall auffalse
zu setzen. -
Wir haben
state.error
nicht gelöscht. Wenn also die vorherige Anfrage fehlgeschlagen ist, siehst du weiterhin diese Fehlermeldung anstelle einer Ladenachricht. -
Wenn der Nutzer erneut die Seite wechselt, während die Seite geladen wird, wer weiß, was dann passiert. Vielleicht sieht er eine neue Seite und dann eine Fehlermeldung, oder die erste Seite und nicht die zweite, je nachdem, in welcher Reihenfolge die Antworten zurückkommen.
Das Problem ist, dass der Status sowohl zu wenig Informationen enthält (welche Anfrage ist fehlgeschlagen? welche wird gerade geladen?) als auch zu viele: Der Typ State
erlaubt es, sowohl isLoading
als auch error
zu setzen, obwohl dies einen ungültigen Status darstellt. Das macht sowohl render()
und changePage()
unmöglich, gut zu implementieren.
Hier ist ein besserer Weg, um den Zustand der Anwendung darzustellen:
interface
RequestPending
{
state
:
'pending'
;
}
interface
RequestError
{
state
:
'error'
;
error
:string
;
}
interface
RequestSuccess
{
state
:
'ok'
;
pageText
:string
;
}
type
RequestState
=
RequestPending
|
RequestError
|
RequestSuccess
;
interface
State
{
currentPage
:string
;
requests
:
{[
page
:string
]
:
RequestState
};
}
Dabei wird eine "tagged union" (auch bekannt als "discriminated union") verwendet, um die verschiedenen Zustände, die eine Netzwerkanfrage haben kann, explizit zu modellieren. Diese Version des Zustands ist drei- bis viermal so lang, hat aber den enormen Vorteil, dass sie keine ungültigen Zustände zulässt. Die aktuelle Seite wird explizit modelliert, ebenso wie der Zustand jeder Anfrage, die du stellst. Daher sind die Funktionen renderPage
und changePage
einfach zu implementieren:
function
renderPage
(
state
:State
)
{
const
{
currentPage
}
=
state
;
const
requestState
=
state
.
requests
[
currentPage
];
switch
(
requestState
.
state
)
{
case
'pending'
:
return
`Loading
${
currentPage
}
...`
;
case
'error'
:
return
`Error! Unable to load
${
currentPage
}
:
${
requestState
.
error
}
`
;
case
'ok'
:
return
`<h1>
${
currentPage
}
</h1>
\
n
${
requestState
.
pageText
}
`
;
}
}
async
function
changePage
(
state
:State
,
newPage
:string
)
{
state
.
requests
[
newPage
]
=
{
state
:
'pending'
};
state
.
currentPage
=
newPage
;
try
{
const
response
=
await
fetch
(
getUrlForPage
(
newPage
));
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Unable to load
${
newPage
}
:
${
response
.
statusText
}
`
);
}
const
pageText
=
await
response
.
text
();
state
.
requests
[
newPage
]
=
{
state
:
'ok'
,
pageText
};
}
catch
(
e
)
{
state
.
requests
[
newPage
]
=
{
state
:
'error'
,
error
:
''
+
e
};
}
}
Die Zweideutigkeit der ersten Implementierung ist vollständig beseitigt: Es ist klar, was die aktuelle Seite ist, und jede Anfrage befindet sich in genau einem Zustand. Wenn der Nutzer die Seite ändert, nachdem eine Anfrage gestellt wurde, ist das auch kein Problem. Die alte Anfrage wird immer noch abgeschlossen, aber sie hat keine Auswirkungen auf die Benutzeroberfläche.
Ein einfacheres, aber noch schlimmeres Beispiel ist das Schicksal von Air France Flug 447, einem Airbus 330, der am 1. Juni 2009 über dem Atlantik verschwand. Der Airbus war ein Fly-by-Wire-Flugzeug, d.h. die Steuereingaben der Piloten gingen durch ein Computersystem, bevor sie sich auf die physischen Steuerflächen des Flugzeugs auswirkten. Nach dem Absturz wurden viele Fragen darüber aufgeworfen, ob es klug ist, sich bei Entscheidungen über Leben und Tod auf Computer zu verlassen. Als zwei Jahre später die Flugschreiber geborgen wurden, enthüllten sie viele Faktoren, die zu dem Absturz führten. Ein wichtiger Faktor war jedoch die schlechte Konstruktion des Flugzeugs.
Das Cockpit des Airbus 330 hatte getrennte Bedienelemente für den Piloten und den Kopiloten. Mit den Seitensteuerknüppeln wurde der Anstellwinkel gesteuert. Wenn du ihn nach hinten ziehst, steigt das Flugzeug, wenn du ihn nach vorne drückst, geht es in den Sturzflug. Der Airbus 330 verwendete ein System namens "Dual Input"-Modus, bei dem sich die beiden seitlichen Steuerknüppel unabhängig voneinander bewegen konnten. Hier siehst du, wie du den Zustand in TypeScript modellieren kannst:
interface
CockpitControls
{
/** Angle of the left side stick in degrees, 0 = neutral, + = forward */
leftSideStick
:number
;
/** Angle of the right side stick in degrees, 0 = neutral, + = forward */
rightSideStick
:number
;
}
Angenommen, du bekommst diese Datenstruktur und sollst eine getStickSetting
Funktion schreiben, die die aktuelle Stick-Einstellung berechnet. Wie würdest du das machen?
Eine Möglichkeit wäre, davon auszugehen, dass der Pilot (der auf der linken Seite sitzt) die Kontrolle hat:
function
getStickSetting
(
controls
:CockpitControls
)
{
return
controls
.
leftSideStick
;
}
Aber was ist, wenn der Kopilot die Kontrolle übernommen hat? Vielleicht solltest du den Steuerknüppel benutzen, der vom Nullpunkt weg ist:
function
getStickSetting
(
controls
:CockpitControls
)
{
const
{
leftSideStick
,
rightSideStick
}
=
controls
;
if
(
leftSideStick
===
0
)
{
return
rightSideStick
;
}
return
leftSideStick
;
}
Aber es gibt ein Problem bei dieser Implementierung: Wir können nur dann sicher sein, dass die linke Einstellung zurückgegeben wird, wenn die rechte neutral ist. Du solltest das also überprüfen:
function
getStickSetting
(
controls
:CockpitControls
)
{
const
{
leftSideStick
,
rightSideStick
}
=
controls
;
if
(
leftSideStick
===
0
)
{
return
rightSideStick
;
}
else
if
(
rightSideStick
===
0
)
{
return
leftSideStick
;
}
// ???
}
Was tust du, wenn beide Werte ungleich Null sind? Hoffentlich sind sie ungefähr gleich, dann kannst du einfach den Durchschnitt bilden:
function
getStickSetting
(
controls
:CockpitControls
)
{
const
{
leftSideStick
,
rightSideStick
}
=
controls
;
if
(
leftSideStick
===
0
)
{
return
rightSideStick
;
}
else
if
(
rightSideStick
===
0
)
{
return
leftSideStick
;
}
if
(
Math
.
abs
(
leftSideStick
-
rightSideStick
)
<
5
)
{
return
(
leftSideStick
+
rightSideStick
)
/
2
;
}
// ???
}
Aber was, wenn sie es nicht sind? Kannst du einen Fehler auslösen? Nicht wirklich: Die Querruder müssen in einem bestimmten Winkel eingestellt sein!
An Bord von Air France 447 zog der Kopilot seinen Steuerknüppel leise zurück, als das Flugzeug in einen Sturm geriet. Es gewann an Höhe, verlor aber schließlich an Geschwindigkeit und geriet in einen Strömungsabriss, bei dem sich das Flugzeug zu langsam bewegt, um effektiv Auftrieb zu erzeugen. Es begann zu sinken.
Um einem Strömungsabriss zu entgehen, sind Piloten darauf trainiert, die Steuerknüppel nach vorne zu drücken, damit das Flugzeug in den Sturzflug übergeht und wieder an Geschwindigkeit gewinnt. Genau das hat der Pilot getan. Aber der Kopilot zog immer noch stillschweigend an seinem Seitensteuer. Und die Funktion des Airbus sah so aus:
function
getStickSetting
(
controls
:CockpitControls
)
{
return
(
controls
.
leftSideStick
+
controls
.
rightSideStick
)
/
2
;
}
Obwohl der Pilot den Steuerknüppel voll nach vorne drückte, ging er im Durchschnitt ins Leere. Er hatte keine Ahnung, warum das Flugzeug nicht tauchte. Als der Copilot herausfand, was er getan hatte, hatte das Flugzeug bereits zu viel Höhe verloren, um sich zu erholen, und stürzte ins Meer, wobei alle 228 Menschen an Bord ums Leben kamen.
Der Punkt ist, dass es keinen guten Weg gibt, getStickSetting
mit dieser Eingabe zu implementieren! Die Funktion wurde so eingerichtet, dass sie fehlschlägt. In den meisten Flugzeugen sind die beiden Steuerknüppel mechanisch miteinander verbunden. Wenn der Copilot den Steuerknüppel zurückzieht, zieht auch der Pilot den Steuerknüppel zurück. Der Zustand dieser Steuerungen lässt sich einfach ausdrücken:
interface
CockpitControls
{
/** Angle of the stick in degrees, 0 = neutral, + = forward */
stickAngle
:number
;
}
Und nun, wie in dem Zitat von Fred Brooks am Anfang des Kapitels, sind unsere Flussdiagramme offensichtlich. Du brauchst die Funktion getStickSetting
überhaupt nicht.
Wenn du deine Typen entwirfst, solltest du darauf achten, welche Werte du einbeziehst und welche du ausschließt. Wenn du nur Werte zulässt, die gültige Zustände repräsentieren, lässt sich dein Code leichter schreiben und TypeScript hat es leichter, ihn zu überprüfen. Dies ist ein sehr allgemeines Prinzip, das in den anderen Abschnitten dieses Kapitels noch genauer erläutert wird.
Dinge zum Erinnern
Punkt 29: Sei liberal in dem, was du akzeptierst und streng in dem, was du produzierst
Diese Idee ist als Robustheitsprinzip oder Postel's Law bekannt, nach Jon Postel, der es im Zusammenhang mit TCP geschrieben hat:
TCP-Implementierungen sollten einem allgemeinen Grundsatz der Robustheit folgen: Sei konservativ in dem, was du tust, sei liberal in dem, was du von anderen akzeptierst.
Eine ähnliche Regel gilt für die Verträge für Funktionen. Es ist in Ordnung, wenn deine Funktionen breit gefächert sind, was sie als Eingaben akzeptieren, aber sie sollten im Allgemeinen spezifischer sein, was sie als Ausgaben produzieren.
Eine 3D-Mapping-API könnte zum Beispiel eine Möglichkeit bieten, die Kamera zu positionieren und ein Ansichtsfenster für eine Bounding Box zu berechnen:
declare
function
setCamera
(
camera
:CameraOptions
)
:
void
;
declare
function
viewportForBounds
(
bounds
:LngLatBounds
)
:
CameraOptions
;
Es ist praktisch, dass das Ergebnis von viewportForBounds
direkt an setCamera
übergeben werden kann, um die Kamera zu positionieren.
Schauen wir uns die Definitionen dieser Typen an:
interface
CameraOptions
{
center?
:LngLat
;
zoom?
:number
;
bearing?
:number
;
pitch?
:number
;
}
type
LngLat
=
{
lng
:number
;
lat
:number
;
}
|
{
lon
:number
;
lat
:number
;
}
|
[
number
,
number
];
Die Felder in CameraOptions
sind alle optional, denn es kann sein, dass du nur den Mittelpunkt oder den Zoom einstellen willst, ohne die Peilung oder den Abstand zu ändern. Durch den Typ LngLat
ist setCamera
außerdem sehr flexibel bei der Annahme von Objekten: Du kannst ein {lng, lat}
Objekt, ein {lon, lat}
Objekt oder ein [lng, lat]
Paar übergeben, wenn du dir sicher bist, dass du die richtige Reihenfolge erwischt hast. Diese Anpassungen machen den Aufruf der Funktion einfach.
Die Funktion viewportForBounds
nimmt einen anderen "liberalen" Typ auf:
type
LngLatBounds
=
{
northeast
:LngLat
,
southwest
:LngLat
}
|
[
LngLat
,
LngLat
]
|
[
number
,
number
,
number
,
number
];
Du kannst die Grenzen entweder mit benannten Ecken, einem Paar von Breiten- und Längengraden oder einem Vierertupel angeben, wenn du sicher bist, dass du die richtige Reihenfolge erwischt hast. Da LngLat
bereits drei Formen zulässt, gibt es nicht weniger als 19 mögliche Formen für LngLatBounds
. Wirklich liberal!
Jetzt lass uns eine Funktion schreiben, die den Viewport an ein GeoJSON Feature anpasst und den neuen Viewport in der URL speichert (für eine Definition von calculateBoundingBox
siehe Punkt 31):
function
focusOnFeature
(
f
:Feature
)
{
const
bounds
=
calculateBoundingBox
(
f
);
const
camera
=
viewportForBounds
(
bounds
);
setCamera
(
camera
);
const
{
center
:
{
lat
,
lng
},
zoom
}
=
camera
;
// ~~~ Property 'lat' does not exist on type ...
// ~~~ Property 'lng' does not exist on type ...
zoom
;
// Type is number | undefined
window
.
location
.
search
=
`?v=@
${
lat
}
,
${
lng
}
z
${
zoom
}
`
;
}
Huch! Es gibt nur die Eigenschaft zoom
, aber ihr Typ wird als number|undefined
abgeleitet, was ebenfalls problematisch ist. Das Problem ist, dass die Typdeklaration für viewportForBounds
darauf hinweist, dass sie nicht nur in dem, was sie akzeptiert, sondern auch in dem, was sie produziert, liberal ist. Die einzige typsichere Möglichkeit, das Ergebnis von camera
zu verwenden, besteht darin, für jede Komponente des Union-Typs einen Code-Zweig einzuführen(Punkt 22).
Der Rückgabetyp mit vielen optionalen Eigenschaften und Union-Typen macht viewportForBounds
schwierig zu verwenden. Der breite Parametertyp ist praktisch, aber der breite Rückgabetyp ist es nicht. Eine bequemere API wäre streng in dem, was sie produziert.
Eine Möglichkeit, dies zu tun, besteht darin, ein kanonisches Format für Koordinaten zu unterscheiden. In Anlehnung an die Konvention von JavaScript zur Unterscheidung von "Array" und "Array-ähnlich"(Punkt 16) kannst du zwischen LngLat
und LngLatLike
unterscheiden. Du kannst auch zwischen einem vollständig definierten Typ Camera
und der von setCamera
akzeptierten Teilversion unterscheiden:
interface
LngLat
{
lng
:number
;
lat
:number
;
};
type
LngLatLike
=
LngLat
|
{
lon
:number
;
lat
:number
;
}
|
[
number
,
number
];
interface
Camera
{
center
:LngLat
;
zoom
:number
;
bearing
:number
;
pitch
:number
;
}
interface
CameraOptions
extends
Omit
<
Partial
<
Camera
>
,
'center'
>
{
center?
:LngLatLike
;
}
type
LngLatBounds
=
{
northeast
:LngLatLike
,
southwest
:LngLatLike
}
|
[
LngLatLike
,
LngLatLike
]
|
[
number
,
number
,
number
,
number
];
declare
function
setCamera
(
camera
:CameraOptions
)
:
void
;
declare
function
viewportForBounds
(
bounds
:LngLatBounds
)
:
Camera
;
Der lose CameraOptions
Typ passt sich dem strengeren Camera
Typ an(Punkt 14).
Die Verwendung von Partial<Camera>
als Parametertyp in setCamera
würde hier nicht funktionieren, da du LngLatLike
Objekte für die Eigenschaft center
zulassen willst. Außerdem kannst du nicht "CameraOptions extends Partial<Camera>
" schreiben, da LngLatLike
eine Obermenge von LngLat
ist, keine Untermenge(Punkt 7). Wenn dir das zu kompliziert erscheint, kannst du den Typ auch explizit ausschreiben, was allerdings einige Wiederholungen mit sich bringt:
interface
CameraOptions
{
center?
:LngLatLike
;
zoom?
:number
;
bearing?
:number
;
pitch?
:number
;
}
In jedem Fall besteht die Funktion focusOnFeature
mit diesen neuen Typendeklarationen die Typprüfung:
function
focusOnFeature
(
f
:Feature
)
{
const
bounds
=
calculateBoundingBox
(
f
);
const
camera
=
viewportForBounds
(
bounds
);
setCamera
(
camera
);
const
{
center
:
{
lat
,
lng
},
zoom
}
=
camera
;
// OK
zoom
;
// Type is number
window
.
location
.
search
=
`?v=@
${
lat
}
,
${
lng
}
z
${
zoom
}
`
;
}
Diesmal ist der Typ von zoom
number
, statt number|undefined
. Die Funktion viewportForBounds
ist jetzt viel einfacher zu benutzen. Wenn es noch andere Funktionen gäbe, die Schranken erzeugen, müsstest du auch eine kanonische Form und eine Unterscheidung zwischen LngLatBounds
und LngLatBoundsLike
einführen.
Ist es ein gutes Design, 19 mögliche Formen von Bounding Boxen zuzulassen? Vielleicht nicht. Aber wenn du Typendeklarationen für eine Bibliothek schreibst, die das tut, musst du ihr Verhalten modellieren. Aber bitte nicht mit 19 Rückgabetypen!
Dinge zum Erinnern
-
Eingabetypen sind in der Regel breiter gefasst als Ausgabetypen. Optionale Eigenschaften und Vereinigungsarten sind bei Parametertypen häufiger anzutreffen als bei Rückgabetypen.
-
Um Typen zwischen Parametern und Rückgabetypen wiederzuverwenden, führe eine kanonische Form (für Rückgabetypen) und eine lockerere Form (für Parameter) ein.
Punkt 30: Keine Wiederholung von Typinformationen in der Dokumentation
Was ist falsch an diesem Code?
/**
* Returns a string with the foreground color.
* Takes zero or one arguments. With no arguments, returns the
* standard foreground color. With one argument, returns the foreground color
* for a particular page.
*/
function
getForegroundColor
(
page?
:string
)
{
return
page
===
'login'
?
{
r
:127
,
g
:127
,
b
:127
}
:
{
r
:0
,
g
:0
,
b
:0
};
}
Der Code und der Kommentar stimmen nicht überein! Ohne mehr Kontext ist es schwer zu sagen, was richtig ist, aber irgendetwas stimmt eindeutig nicht. Wie ein Professor von mir zu sagen pflegte: "Wenn dein Code und deine Kommentare nicht übereinstimmen, sind sie beide falsch!"
Gehen wir davon aus, dass der Code das gewünschte Verhalten darstellt. Es gibt ein paar Probleme mit diesem Kommentar:
-
Es heißt, dass die Funktion die Farbe als
string
zurückgibt, obwohl sie eigentlich ein{r, g, b}
Objekt zurückgibt. -
Sie erklärt, dass die Funktion null oder ein Argument benötigt, was bereits aus der Typsignatur ersichtlich ist.
-
Er ist unnötig wortreich: Der Kommentar ist länger als die Funktionsdeklaration und die Implementierung!
Das TypeScript-System ist so konzipiert, dass es kompakt, beschreibend und lesbar ist. Seine Entwickler sind Sprachexperten mit jahrzehntelanger Erfahrung. Es ist mit Sicherheit ein besserer Weg, die Typen der Ein- und Ausgänge deiner Funktion auszudrücken als deine Prosa!
Und da deine Typ-Annotationen vom TypeScript-Compiler überprüft werden, können sie nie mit der Implementierung nicht übereinstimmen. Vielleicht hat getForegroundColor
früher einen String zurückgegeben, wurde aber später geändert, um ein Objekt zurückzugeben. Derjenige, der die Änderung vorgenommen hat, hat vielleicht vergessen, den langen Kommentar zu aktualisieren.
Nichts bleibt synchron, es sei denn, es wird dazu gezwungen. Mit Typ-Annotationen ist TypeScripts Typprüfung dieser Zwang! Wenn du Typinformationen in Annotationen und nicht in die Dokumentation schreibst, kannst du dich darauf verlassen, dass sie auch bei der Weiterentwicklung des Codes korrekt bleiben.
Ein besserer Kommentar könnte wie folgt aussehen:
/** Get the foreground color for the application or a specific page. */
function
getForegroundColor
(
page?
:string
)
:
Color
{
// ...
}
Wenn du einen bestimmten Parameter beschreiben willst, verwende eine @param
JSDoc-Anmerkung. Mehr dazu findest du unter Punkt 48.
Kommentare über fehlende Mutationen sind ebenfalls verdächtig. Sag nicht einfach, dass du einen Parameter nicht veränderst:
/** Does not modify nums */
function
sort
(
nums
:number
[])
{
/* ... */
}
Deklariere sie stattdessen als readonly
(Punkt 17) und lass TypeScript den Vertrag erzwingen:
function
sort
(
nums
:readonly
number
[])
{
/* ... */
}
Was für Kommentare gilt, gilt auch für Variablennamen. Vermeide es, Typen in ihnen zu verwenden: Anstatt eine Variable ageNum
zu nennen, nenne sie age
und stelle sicher, dass sie wirklich eine number
ist.
Eine Ausnahme hiervon sind Zahlen mit Einheiten. Wenn nicht klar ist, um welche Einheiten es sich handelt, solltest du sie in den Namen einer Variablen oder Eigenschaft aufnehmen. Zum Beispiel ist timeMs
ein viel eindeutigerer Name als time
und temperatureC
ist ein viel eindeutigerer Name als temperature
. Punkt 37 beschreibt "Marken", die einen typsicheren Ansatz zur Modellierung von Einheiten bieten.
Dinge zum Erinnern
-
Vermeide die Wiederholung von Typinformationen in Kommentaren und Variablennamen. Im besten Fall ist es eine Verdoppelung der Typdeklarationen, im schlimmsten Fall führt es zu widersprüchlichen Informationen.
-
Erwäge, Einheiten in die Variablennamen aufzunehmen, wenn sie nicht eindeutig aus dem Typ hervorgehen (z. B.
timeMs
odertemperatureC
).
Punkt 31: Schiebe Nullwerte an den Rand deiner Typen
Wenn du zum ersten Mal strictNullChecks
einschaltest, kann es dir so vorkommen, als müsstest du in deinem Code unzählige if-Anweisungen einfügen, die auf null
und undefined
Werte prüfen. Das liegt oft daran, dass die Beziehungen zwischen Null- und Nicht-Null-Werten implizit sind: Wenn Variable A nicht Null ist, weißt du, dass Variable B auch nicht Null ist und umgekehrt. Diese impliziten Beziehungen sind sowohl für die Leser deines Codes als auch für den Typprüfer verwirrend.
Es ist einfacher, mit Werten zu arbeiten, wenn sie entweder komplett null oder komplett nicht-null sind, als mit einer Mischung. Du kannst dies modellieren, indem du die Nullwerte an den Rand deiner Strukturen schiebst.
Angenommen, du willst das Minimum und Maximum einer Liste von Zahlen berechnen. Wir nennen das den "Umfang". Hier ist ein Versuch:
function
extent
(
nums
:number
[])
{
let
min
,
max
;
for
(
const
num
of
nums
)
{
if
(
!
min
)
{
min
=
num
;
max
=
num
;
}
else
{
min
=
Math
.
min
(
min
,
num
);
max
=
Math
.
max
(
max
,
num
);
}
}
return
[
min
,
max
];
}
Der Code prüft den Typ (ohne strictNullChecks
) und hat einen abgeleiteten Rückgabetyp von number[]
, was in Ordnung zu sein scheint. Aber er hat einen Fehler und einen Konstruktionsfehler:
-
Wenn der Minimal- oder Maximalwert Null ist, kann er überschrieben werden. Zum Beispiel gibt
extent([0, 1, 2])
[1, 2]
und nicht[0, 2]
zurück. -
Wenn das Array
nums
leer ist, gibt die Funktion[undefined, undefined]
zurück. Diese Art von Objekt mit mehrerenundefined
ist für Kunden schwer zu handhaben und ist genau die Art von Typ, von der dieser Artikel abrät. Aus dem Quellcode wissen wir, dassmin
undmax
entweder beideundefined
sind oder keines von beiden, aber diese Information ist nicht im Typensystem enthalten.
Wenn du strictNullChecks
einschaltest, werden diese beiden Probleme deutlicher:
function
extent
(
nums
:number
[])
{
let
min
,
max
;
for
(
const
num
of
nums
)
{
if
(
!
min
)
{
min
=
num
;
max
=
num
;
}
else
{
min
=
Math
.
min
(
min
,
num
);
max
=
Math
.
max
(
max
,
num
);
// ~~~ Argument of type 'number | undefined' is not
// assignable to parameter of type 'number'
}
}
return
[
min
,
max
];
}
Der Rückgabetyp von extent
wird nun als (number | undefined)[]
abgeleitet, was den Designfehler deutlicher macht. Dies wird sich wahrscheinlich als Typfehler manifestieren, wo immer du extent
aufrufst:
const
[
min
,
max
]
=
extent
([
0
,
1
,
2
]);
const
span
=
max
-
min
;
// ~~~ ~~~ Object is possibly 'undefined'
Der Fehler in der Implementierung von extent
kommt daher, dass du undefined
als Wert für min
ausgeschlossen hast, aber nicht max
. Die beiden werden zusammen initialisiert, aber diese Information ist im Typsystem nicht vorhanden. Du könntest den Fehler beheben, indem du eine Prüfung für max
hinzufügst, aber damit würdest du den Fehler nur noch verschlimmern.
Eine bessere Lösung ist es, das Minimum und das Maximum in dasselbe Objekt zu packen und dieses Objekt entweder vollständig null
oder vollständig nichtnull
zu machen:
function
extent
(
nums
:number
[])
{
let
result
:
[
number
,
number
]
|
null
=
null
;
for
(
const
num
of
nums
)
{
if
(
!
result
)
{
result
=
[
num
,
num
];
}
else
{
result
=
[
Math
.
min
(
num
,
result
[
0
]),
Math
.
max
(
num
,
result
[
1
])];
}
}
return
result
;
}
Der Rückgabetyp ist jetzt [number, number] | null
, was für Kunden einfacher zu handhaben ist. Die Min- und Max-Werte können entweder mit einer Nicht-Null-Assertion abgefragt werden:
const
[
min
,
max
]
=
extent
([
0
,
1
,
2
])
!
;
const
span
=
max
-
min
;
// OK
oder einen einzelnen Scheck:
const
range
=
extent
([
0
,
1
,
2
]);
if
(
range
)
{
const
[
min
,
max
]
=
range
;
const
span
=
max
-
min
;
// OK
}
Durch die Verwendung eines einzigen Objekts zur Verfolgung des Umfangs haben wir unser Design verbessert, TypeScript geholfen, die Beziehung zwischen Null-Werten zu verstehen, und den Fehler behoben: Die Prüfung von if (!result)
ist jetzt problemlos.
Eine Mischung aus Null- und Nicht-Null-Werten kann auch in Klassen zu Problemen führen. Nehmen wir zum Beispiel an, du hast eine Klasse, die sowohl einen Benutzer als auch seine Beiträge in einem Forum repräsentiert:
class
UserPosts
{
user
:UserInfo
|
null
;
posts
:Post
[]
|
null
;
constructor
()
{
this
.
user
=
null
;
this
.
posts
=
null
;
}
async
init
(
userId
:string
)
{
return
Promise
.
all
([
async
()
=>
this
.
user
=
await
fetchUser
(
userId
),
async
()
=>
this
.
posts
=
await
fetchPostsForUser
(
userId
)
]);
}
getUserName() {
// ...?
}
}
Während die beiden Netzwerkanfragen geladen werden, werden die Eigenschaften user
und posts
null
sein. Zu jeder Zeit können beide null
sein, eine kann null
sein, oder beide können nichtnull
sein. Es gibt vier Möglichkeiten. Diese Komplexität wird in jede Methode der Klasse einfließen. Dieses Design wird mit ziemlicher Sicherheit zu Verwirrung, einer Vielzahl von null
Prüfungen und Fehlern führen.
Ein besserer Entwurf würde warten, bis alle Daten, die von der Klasse verwendet werden, verfügbar sind:
class
UserPosts
{
user
:UserInfo
;
posts
:Post
[];
constructor
(
user
:UserInfo
,
posts
:Post
[])
{
this
.
user
=
user
;
this
.
posts
=
posts
;
}
static
async
init
(
userId
:string
)
:
Promise
<
UserPosts
>
{
const
[
user
,
posts
]
=
await
Promise
.
all
([
fetchUser
(
userId
),
fetchPostsForUser
(
userId
)
]);
return
new
UserPosts
(
user
,
posts
);
}
getUserName() {
return
this
.
user
.
name
;
}
}
Jetzt ist die Klasse UserPosts
vollständig nichtnull
, und es ist einfach, korrekte Methoden für sie zu schreiben. Wenn du natürlich Operationen durchführen musst, während die Daten teilweise geladen sind, musst du dich mit der Vielzahl von null
und nichtnull
Zuständen auseinandersetzen.
(Lass dich nicht dazu verleiten, nullbare Eigenschaften durch Promises zu ersetzen. Das führt in der Regel zu noch verwirrenderem Code und zwingt alle deine Methoden dazu, asynchron zu sein. Promises verdeutlichen den Code, der Daten lädt, haben aber eher den gegenteiligen Effekt auf die Klasse, die diese Daten verwendet).
Dinge zum Erinnern
-
Vermeide Designs, in denen ein Wert, der
null
oder nichtnull
ist, implizit mit einem anderen Wert, dernull
oder nichtnull
ist, verbunden ist. -
Schiebe die Werte von
null
an den Rand deiner API, indem du größere Objekte entweder zunull
oder vollständig zunull
machst. Dadurch wird der Code sowohl für menschliche Leser als auch für den Typprüfer klarer. -
Ziehe in Erwägung, eine vollständig nicht
null
Klasse zu erstellen und sie zu konstruieren, wenn alle Werte verfügbar sind. -
strictNullChecks
kann zwar viele Probleme in deinem Code aufzeigen, ist aber unverzichtbar, wenn es darum geht, das Verhalten von Funktionen in Bezug auf Nullwerte aufzudecken.
Punkt 32: Bevorzuge Unionen von Schnittstellen gegenüber Schnittstellen von Gewerkschaften
Wenn du eine Schnittstelle erstellst, deren Eigenschaften Vereinigungstypen sind, solltest du dich fragen, ob der Typ als Vereinigung von genaueren Schnittstellen sinnvoller wäre.
Angenommen, du baust ein Vektorzeichenprogramm und möchtest eine Schnittstelle für Ebenen mit bestimmten Geometrietypen definieren:
interface
Layer
{
layout
:FillLayout
|
LineLayout
|
PointLayout
;
paint
:FillPaint
|
LinePaint
|
PointPaint
;
}
Das Feld layout
bestimmt, wie und wo die Formen gezeichnet werden (abgerundete Ecken? gerade?), während das Feld paint
den Stil bestimmt (ist die Linie blau? dick? dünn? gestrichelt?).
Wäre es sinnvoll, eine Ebene zu haben, deren layout
LineLayout
ist, aber deren paint
Eigenschaft FillPaint
ist? Wahrscheinlich nicht. Diese Möglichkeit zuzulassen, macht die Benutzung der Bibliothek fehleranfälliger und erschwert die Arbeit mit dieser Schnittstelle.
Eine bessere Möglichkeit, dies zu modellieren, sind separate Schnittstellen für jede Art von Schicht:
interface
FillLayer
{
layout
:FillLayout
;
paint
:FillPaint
;
}
interface
LineLayer
{
layout
:LineLayout
;
paint
:LinePaint
;
}
interface
PointLayer
{
layout
:PointLayout
;
paint
:PointPaint
;
}
type
Layer
=
FillLayer
|
LineLayer
|
PointLayer
;
Indem du Layer
auf diese Weise definierst, hast du die Möglichkeit von gemischten layout
und paint
Eigenschaften ausgeschlossen. Dies ist ein Beispiel dafür, dass du den Rat aus Artikel 28befolgst, Typen zu bevorzugen, die nur gültige Zustände darstellen.
Das häufigste Beispiel für dieses Muster ist die "tagged union" (oder "discriminated union"). In diesem Fall ist eine der Eigenschaften eine Vereinigung von String-Literaltypen:
interface
Layer
{
type
:'fill'
|
'line'
|
'point'
;
layout
:FillLayout
|
LineLayout
|
PointLayout
;
paint
:FillPaint
|
LinePaint
|
PointPaint
;
}
Wäre es sinnvoll, wie bisher type: 'fill'
zu haben, aber dann eine LineLayout
und PointPaint
? Sicherlich nicht. Wandle Layer
in eine Vereinigung von Schnittstellen um, um diese Möglichkeit auszuschließen:
interface
FillLayer
{
type
:'fill'
;
layout
:FillLayout
;
paint
:FillPaint
;
}
interface
LineLayer
{
type
:'line'
;
layout
:LineLayout
;
paint
:LinePaint
;
}
interface
PointLayer
{
type
:'paint'
;
layout
:PointLayout
;
paint
:PointPaint
;
}
type
Layer
=
FillLayer
|
LineLayer
|
PointLayer
;
Die Eigenschaft type
ist das "Tag" und kann verwendet werden, um zu bestimmen, mit welchem Typ von Layer
du zur Laufzeit arbeitest. TypeScript kann auch den Typ von Layer
anhand des Tags eingrenzen:
function
drawLayer
(
layer
:Layer
)
{
if
(
layer
.
type
===
'fill'
)
{
const
{
paint
}
=
layer
;
// Type is FillPaint
const
{
layout
}
=
layer
;
// Type is FillLayout
}
else
if
(
layer
.
type
===
'line'
)
{
const
{
paint
}
=
layer
;
// Type is LinePaint
const
{
layout
}
=
layer
;
// Type is LineLayout
}
else
{
const
{
paint
}
=
layer
;
// Type is PointPaint
const
{
layout
}
=
layer
;
// Type is PointLayout
}
}
Indem du die Beziehung zwischen den Eigenschaften in diesem Typ korrekt modellierst, hilfst du TypeScript, die Korrektheit deines Codes zu überprüfen. Derselbe Code, der die ursprüngliche Layer
Definition enthält, wäre mit Type Assertions überladen gewesen.
Weil sie so gut mit dem Type-Checker von TypeScript funktionieren, sind Tagged Unions in TypeScript-Code allgegenwärtig. Erkenne dieses Muster und wende es an, wenn du kannst. Wenn du einen Datentyp in TypeScript mit einer Tagged Union darstellen kannst, ist es meist eine gute Idee, dies zu tun. Wenn du dir optionale Felder als eine Vereinigung ihres Typs und von undefined
vorstellst, passen sie ebenfalls in dieses Muster. Betrachte diesen Typ:
interface
Person
{
name
:string
;
// These will either both be present or not be present
placeOfBirth?
:string
;
dateOfBirth?
:Date
;
}
Der Kommentar mit den Typinformationen ist ein deutliches Zeichen dafür, dass es ein Problem geben könnte(Punkt 30). Es gibt eine Beziehung zwischen den Feldern placeOfBirth
und dateOfBirth
, die du TypeScript nicht mitgeteilt hast.
Ein besserer Weg, dies zu modellieren, ist, diese beiden Eigenschaften in ein einziges Objekt zu verschieben. Das entspricht dem Verschieben der Werte von null
in den Umkreis(Punkt 31):
interface
Person
{
name
:string
;
birth
?:
{
place
:string
;
date
:Date
;
}
}
Jetzt TypeScript beschwert sich über Werte mit einem Ort, aber keinem Geburtsdatum:
const
alanT
:Person
=
{
name
:
'Alan Turing'
,
birth
:
{
// ~~~~ Property 'date' is missing in type
// '{ place: string; }' but required in type
// '{ place: string; date: Date; }'
place
:
'London'
}
}
Außerdem muss eine Funktion, die ein Person
Objekt annimmt, nur eine einzige Prüfung durchführen:
function
eulogize
(
p
:Person
)
{
console
.
log
(
p
.
name
);
const
{
birth
}
=
p
;
if
(
birth
)
{
console
.
log
(
`was born on
${
birth
.
date
}
in
${
birth
.
place
}
.`
);
}
}
Wenn die Struktur des Typs außerhalb deiner Kontrolle liegt (z. B. wenn er von einer API kommt), kannst du die Beziehung zwischen diesen Feldern immer noch mit der bereits bekannten Vereinigung von Schnittstellen modellieren:
interface
Name
{
name
:string
;
}
interface
PersonWithBirth
extends
Name
{
placeOfBirth
:string
;
dateOfBirth
:Date
;
}
type
Person
=
Name
|
PersonWithBirth
;
Jetzt bekommst du einige der gleichen Vorteile wie bei dem verschachtelten Objekt:
function
eulogize
(
p
:Person
)
{
if
(
'placeOfBirth'
in
p
)
{
p
// Type is PersonWithBirth
const
{
dateOfBirth
}
=
p
// OK, type is Date
}
}
In beiden Fällen macht die Typdefinition die Beziehung zwischen den Eigenschaften deutlicher.
Dinge zum Erinnern
-
Schnittstellen mit mehreren Eigenschaften, die Unionstypen sind, sind oft ein Fehler, weil sie die Beziehungen zwischen diesen Eigenschaften verschleiern.
-
Unions von Schnittstellen sind präziser und können von TypeScript verstanden werden.
-
Erwäge, ein "Tag" zu deiner Struktur hinzuzufügen, um die Kontrollflussanalyse von TypeScript zu erleichtern. Weil sie so gut unterstützt werden, sind "tagged unions" in TypeScript-Code allgegenwärtig.
Punkt 33: Genauere Alternativen zu String-Typen bevorzugen
Der Bereich des Typs string
ist groß: "x"
und "y"
liegen darin, aber auch der gesamte Text von Moby Dick (er beginnt mit "Call me Ishmael…"
und ist etwa 1,2 Millionen Zeichen lang). Wenn du eine Variable des Typs string
deklarierst, solltest du dich fragen, ob ein engerer Typ besser geeignet wäre.
Angenommen, du baust eine Musiksammlung auf und möchtest einen Typ für ein Album definieren. Hier ist ein Versuch:
interface
Album
{
artist
:string
;
title
:string
;
releaseDate
:string
;
// YYYY-MM-DD
recordingType
:string
;
// E.g., "live" or "studio"
}
Die Häufigkeit der string
Typen und die Typinformationen in den Kommentaren (siehe Punkt 30) sind starke Hinweise darauf, dass diese interface
nicht ganz richtig ist. Hier ist was schief gehen kann:
const
kindOfBlue
:Album
=
{
artist
:
'Miles Davis'
,
title
:
'Kind of Blue'
,
releaseDate
:
'August 17th, 1959'
,
// Oops!
recordingType
:
'Studio'
,
// Oops!
};
// OK
Das Feld releaseDate
ist falsch formatiert (laut Kommentar) und "Studio"
wird großgeschrieben, obwohl es klein geschrieben werden sollte. Da es sich bei diesen Werten aber um Zeichenketten handelt, kann dieses Objekt Album
zugewiesen werden und die Typüberprüfung beanstandet nichts.
Diese breiten string
Typen können auch Fehler für gültige Album
Objekte maskieren. Zum Beispiel:
function
recordRelease
(
title
:string
,
date
:string
)
{
/* ... */
}
recordRelease
(
kindOfBlue
.
releaseDate
,
kindOfBlue
.
title
);
// OK, should be error
Beim Aufruf von recordRelease
sind die Parameter vertauscht, aber beide sind Strings, so dass die Typüberprüfung keine Beanstandungen ergibt. Wegen der weiten Verbreitung von string
Typen wird Code wie dieser manchmal als "stringly typed" bezeichnet.
Kannst du die Typen enger fassen, um diese Art von Problemen zu vermeiden? Der vollständige Text von Moby Dick wäre zwar ein schwerfälliger Künstlername oder Albumtitel, aber er ist zumindest plausibel. Daher ist string
für diese Felder geeignet. Für das Feld releaseDate
ist es besser, einfach ein Date
Objekt zu verwenden, um Probleme mit der Formatierung zu vermeiden. Für das Feld recordingType
schließlich kannst du einen Union-Typ mit nur zwei Werten definieren (du könntest auch ein enum
verwenden, aber ich empfehle generell, diese zu vermeiden; siehe Punkt 53):
type
RecordingType
=
'studio'
|
'live'
;
interface
Album
{
artist
:string
;
title
:string
;
releaseDate
:Date
;
recordingType
:RecordingType
;
}
Mit diesen Änderungen ist TypeScript in der Lage, eine gründlichere Prüfung auf Fehler durchzuführen:
const
kindOfBlue
:Album
=
{
artist
:
'Miles Davis'
,
title
:
'Kind of Blue'
,
releaseDate
:new
Date
(
'1959-08-17'
),
recordingType
:
'Studio'
// ~~~~~~~~~~~~ Type '"Studio"' is not assignable to type 'RecordingType'
};
Dieser Ansatz hat neben der strengeren Prüfung noch weitere Vorteile. Erstens stellt die explizite Definition des Typs sicher, dass seine Bedeutung nicht verloren geht, wenn er weitergegeben wird. Wenn du z. B. nur Alben eines bestimmten Aufnahmetyps finden möchtest, könntest du eine Funktion wie diese definieren:
function
getAlbumsOfType
(
recordingType
:string
)
:
Album
[]
{
// ...
}
Woher soll der Aufrufer dieser Funktion wissen, was recordingType
sein soll? Es ist nur ein string
. Der Kommentar, der erklärt, dass es "studio"
oder "live"
ist, ist in der Definition von Album
versteckt, wo der Benutzer vielleicht nicht nachschauen würde.
Zweitens kannst du durch die explizite Definition eines Typs eine Dokumentation zu diesem Typ hinzufügen (siehe Punkt 48):
/** What type of environment was this recording made in? */
type
RecordingType
=
'live'
|
'studio'
;
Wenn du getAlbumsOfType
änderst, um eine RecordingType
zu nehmen, kann sich der Anrufer durchklicken und die Dokumentation sehen (siehe Abbildung 4-1).
Ein weiterer häufiger Missbrauch von string
ist in Funktionsparametern. Angenommen, du willst eine Funktion schreiben, die alle Werte für ein einzelnes Feld in einem Array herauszieht. Die Underscore-Bibliothek nennt das "pluck":
function
pluck
(
records
,
key
)
{
return
records
.
map
(
r
=>
r
[
key
]);
}
Wie würdest du das tippen? Hier ist ein erster Versuch:
function
pluck
(
records
:any
[],
key
:string
)
:
any
[]
{
return
records
.
map
(
r
=>
r
[
key
]);
}
Dieser Typ prüft, ist aber nicht gut. Die any
Typen sind problematisch, insbesondere beim Rückgabewert (siehe Punkt 38). Der erste Schritt zur Verbesserung der Typsignatur ist die Einführung eines generischen Typparameters:
function
pluck
<
T
>
(
records
:T
[],
key
:string
)
:
any
[]
{
return
records
.
map
(
r
=>
r
[
key
]);
// ~~~~~~ Element implicitly has an 'any' type
// because type '{}' has no index signature
}
TypeScript beschwert sich jetzt, dass der string
Typ für key
zu breit ist. Und das zu Recht: Wenn du ein Array von Album
übergibst, gibt es nur vier gültige Werte für key
("artist", "title", "releaseDate" und "recordingType"), im Gegensatz zu der großen Menge an Strings. Das ist genau das, was der Typ keyof Album
ist:
type
K
=
keyof
Album
;
// Type is "artist" | "title" | "releaseDate" | "recordingType"
Die Lösung ist also, string
durch keyof T
zu ersetzen:
function
pluck
<
T
>
(
records
:T
[],
key
:keyof
T
)
{
return
records
.
map
(
r
=>
r
[
key
]);
}
Das übersteht die Typprüfung. Wir haben TypeScript auch den Rückgabetyp ableiten lassen. Wie macht es das? Wenn du in deinem Editor mit der Maus über pluck
fährst, wird der Typ abgeleitet:
function
pluck
<
T
>
(
record
:T
[],
key
:keyof
T
)
:
T
[
keyof
T
][]
T[keyof T]
ist der Typ eines jeden möglichen Wertes in T
. Wenn du eine einzelne Zeichenkette als key
übergibst, ist das zu weit gefasst. Zum Beispiel:
const
releaseDates
=
pluck
(
albums
,
'releaseDate'
);
// Type is (string | Date)[]
Der Typ sollte Date[]
sein, nicht (string | Date)[]
. keyof T
ist zwar viel enger gefasst als string
, aber immer noch zu breit. Um ihn weiter einzugrenzen, müssen wir einen zweiten generischen Parameter einführen, der eine Teilmenge von keyof T
ist (wahrscheinlich ein einzelner Wert):
function
pluck
<
T
,
K
extends
keyof
T
>
(
records
:T
[],
key
:K
)
:
T
[
K
][]
{
return
records
.
map
(
r
=>
r
[
key
]);
}
(Mehr über extends
in diesem Zusammenhang findest du unter Punkt 14).
Die Typsignatur ist jetzt völlig korrekt. Wir können das überprüfen, indem wir pluck
auf verschiedene Arten aufrufen:
pluck
(
albums
,
'releaseDate'
);
// Type is Date[]
pluck
(
albums
,
'artist'
);
// Type is string[]
pluck
(
albums
,
'recordingType'
);
// Type is RecordingType[]
pluck
(
albums
,
'recordingDate'
);
// ~~~~~~~~~~~~~~~ Argument of type '"recordingDate"' is not
// assignable to parameter of type ...
Der Sprachendienst ist sogar in der Lage, die Tasten von Album
automatisch zu vervollständigen (wie in Abbildung 4-2 gezeigt).
string
hat einige der gleichen Probleme wie any
: Bei unsachgemäßer Verwendung erlaubt es ungültige Werte und verbirgt Beziehungen zwischen Typen. Das behindert den Type Checker und kann echte Bugs verbergen. Die Möglichkeit von TypeScript, Untermengen von string
zu definieren, ist eine gute Möglichkeit, JavaScript-Code typsicher zu machen. Durch die Verwendung präziserer Typen werden sowohl Fehler erkannt als auch die Lesbarkeit deines Codes verbessert.
Dinge zum Erinnern
-
Vermeide "stringly typisierten" Code. Bevorzuge geeignetere Typen, bei denen nicht jede
string
eine Möglichkeit ist. -
Ziehe eine Vereinigung von String-Literal-Typen
string
vor, wenn dies den Bereich einer Variablen genauer beschreibt. So erhältst du eine strengere Typüberprüfung und verbesserst die Entwicklungserfahrung. -
Bevorzuge
keyof T
gegenüberstring
für Funktionsparameter, von denen erwartet wird, dass sie Eigenschaften eines Objekts sind.
Punkt 34: Unvollständige Typen gegenüber ungenauen Typen bevorzugen
Unter wirst du beim Schreiben von Typendeklarationen unweigerlich auf Situationen stoßen, in denen du das Verhalten auf eine präzisere oder weniger präzise Weise modellieren kannst. Präzision bei Typen ist im Allgemeinen eine gute Sache, denn sie hilft deinen Benutzern, Fehler zu finden und die Vorteile der Werkzeuge zu nutzen, die TypeScript bietet. Aber pass auf, wenn du die Präzision deiner Typendeklarationen erhöhst: Es ist leicht, Fehler zu machen, und falsche Typen können schlimmer sein als gar keine Typen.
Angenommen, du schreibst Typendeklarationen für GeoJSON, ein Format, das wir bereits in Punkt 31 kennengelernt haben. Eine GeoJSON Geometry kann einer von mehreren Typen sein, die jeweils unterschiedlich geformte Koordinatenfelder haben:
interface
Point
{
type
:'Point'
;
coordinates
:number
[];
}
interface
LineString
{
type
:'LineString'
;
coordinates
:number
[][];
}
interface
Polygon
{
type
:'Polygon'
;
coordinates
:number
[][][];
}
type
Geometry
=
Point
|
LineString
|
Polygon
;
// Also several others
Das ist in Ordnung, aber number[]
für eine Koordinate ist ein bisschen ungenau. In Wirklichkeit handelt es sich um Breiten- und Längengrade, daher wäre ein Tupel-Typ vielleicht besser:
type
GeoPosition
=
[
number
,
number
];
interface
Point
{
type
:'Point'
;
coordinates
:GeoPosition
;
}
// Etc.
Du veröffentlichst deine präziseren Typen in der Welt und wartest auf die Lobeshymnen, die dir entgegenschlagen. Leider beschwert sich ein Nutzer, dass deine neuen Typen alles kaputt gemacht haben. Auch wenn du bisher nur Längen- und Breitengrade verwendet hast, darf eine Position in GeoJSON ein drittes Element, eine Höhe und möglicherweise noch mehr haben. Bei dem Versuch, die Typendeklarationen genauer zu machen, bist du zu weit gegangen und hast die Typen ungenau gemacht! Wenn du deine Typdeklarationen weiter verwenden willst, musst du Typ-Assertions einführen oder den Typ-Checker mit as any
ganz ausschalten.
Ein weiteres Beispiel ist der Versuch, Typendeklarationen für eine Lisp-ähnliche Sprache zu schreiben, die in JSON definiert ist:
12 "red" ["+", 1, 2] // 3 ["/", 20, 2] // 10 ["case", [">", 20, 10], "red", "blue"] // "red" ["rgb", 255, 0, 127] // "#FF007F"
Die Bibliothek Mapbox verwendet ein solches System, um das Aussehen von Kartenmerkmalen auf vielen Geräten zu bestimmen. Es gibt ein ganzes Spektrum an Präzision, mit der du versuchen könntest, dies zu schreiben:
-
Erlaube alles.
-
Erlaube Strings, Zahlen und Arrays.
-
Erlaube Zeichenketten, Zahlen und Arrays, die mit bekannten Funktionsnamen beginnen.
-
Achte darauf, dass jede Funktion die richtige Anzahl von Argumenten erhält.
-
Achte darauf, dass jede Funktion den richtigen Typ von Argumenten erhält.
Die ersten beiden Optionen sind ganz einfach:
type
Expression1
=
any
;
type
Expression2
=
number
|
string
|
any
[];
Darüber hinaus solltest du eine Testmenge von Ausdrücken einführen, die gültig sind und Ausdrücke, die nicht gültig sind. Wenn du deine Typen präziser machst, hilft das, Regressionen zu vermeiden (siehe Punkt 52):
const
tests
:Expression2
[]
=
[
10
,
"red"
,
true
,
// ~~~ Type 'true' is not assignable to type 'Expression2'
[
"+"
,
10
,
5
],
[
"case"
,
[
">"
,
20
,
10
],
"red"
,
"blue"
,
"green"
],
// Too many values
[
"**"
,
2
,
31
],
// Should be an error: no "**" function
[
"rgb"
,
255
,
128
,
64
],
[
"rgb"
,
255
,
0
,
127
,
0
]
// Too many values
];
Um die nächste Präzisionsstufe zu erreichen, kannst du eine Vereinigung von String-Literalen als erstes Element eines Tupels verwenden:
type
FnName
=
'+'
|
'-'
|
'*'
|
'/'
|
'>'
|
'<'
|
'case'
|
'rgb'
;
type
CallExpression
=
[
FnName
,
...
any
[]];
type
Expression3
=
number
|
string
|
CallExpression
;
const
tests
:Expression3
[]
=
[
10
,
"red"
,
true
,
// ~~~ Type 'true' is not assignable to type 'Expression3'
[
"+"
,
10
,
5
],
[
"case"
,
[
">"
,
20
,
10
],
"red"
,
"blue"
,
"green"
],
[
"**"
,
2
,
31
],
// ~~~~~~~~~~~ Type '"**"' is not assignable to type 'FnName'
[
"rgb"
,
255
,
128
,
64
],
[
"rgb"
,
255
,
0
,
127
,
0
]
// Too many values
];
Es gibt einen neuen gefangenen Fehler und keine Rückschritte. Ziemlich gut!
Was wenn du sicherstellen willst, dass jede Funktion die richtige Anzahl von Argumenten erhält? Das wird schwieriger, da der Typ nun rekursiv sein muss, um alle Funktionsaufrufe zu erreichen. TypeScript erlaubt das, allerdings müssen wir den Type Checker davon überzeugen, dass unsere Rekursion nicht unendlich ist. In diesem Fall bedeutet das, dass wir CaseCall
(das ein Array mit gerader Länge sein muss) mit interface
statt mit type
definieren. Das ist möglich, wenn auch etwas umständlich:
type
Expression4
=
number
|
string
|
CallExpression
;
type
CallExpression
=
MathCall
|
CaseCall
|
RGBCall
;
type
MathCall
=
[
'+'
|
'-'
|
'/'
|
'*'
|
'>'
|
'<'
,
Expression4
,
Expression4
,
];
interface
CaseCall
{
0
:
'case'
;
1
:Expression4
;
2
:Expression4
;
3
:Expression4
;
4?
:Expression4
;
5?
:Expression4
;
// etc.
length
:4
|
6
|
8
|
10
|
12
|
14
|
16
;
// etc.
}
type
RGBCall
=
[
'rgb'
,
Expression4
,
Expression4
,
Expression4
];
const
tests
:Expression4
[]
=
[
10
,
"red"
,
true
,
// ~~~ Type 'true' is not assignable to type 'Expression4'
[
"+"
,
10
,
5
],
[
"case"
,
[
">"
,
20
,
10
],
"red"
,
"blue"
,
"green"
],
// ~~~~~~ ~~~~~~~
// Type '"case"' is not assignable to type '"rgb"'.
// Type 'string' is not assignable to type 'undefined'.
[
"**"
,
2
,
31
],
// ~~~~ Type '"**"' is not assignable to type '"+" | "-" | "/" | ...
[
"rgb"
,
255
,
128
,
64
],
[
"rgb"
,
255
,
0
,
127
,
0
]
// ~ Type 'number' is not assignable to type 'undefined'.
];
Jetzt führen alle ungültigen Ausdrücke zu Fehlern. Und es ist interessant, dass du etwas wie "ein Array mit gerader Länge" mit einem TypeScript interface
ausdrücken kannst. Aber einige dieser Fehlermeldungen sind nicht sehr gut, insbesondere die, dass "case"
nicht "rgb"
zugewiesen werden kann.
Ist dies eine Verbesserung gegenüber den vorherigen, weniger präzisen Typen? Die Tatsache, dass du bei einigen falschen Verwendungen Fehler bekommst, ist ein Gewinn, aber verwirrende Fehlermeldungen machen es schwieriger, mit diesem Typ zu arbeiten. Sprachdienste gehören genauso zum TypeScript-Erlebnis wie die Typüberprüfung (siehe Punkt 6). Es ist also eine gute Idee, sich die Fehlermeldungen deiner Typdeklarationen anzusehen und die Autovervollständigung in Situationen zu testen, in denen sie funktionieren sollte. Wenn deine neuen Typdeklarationen zwar präziser sind, aber die Autovervollständigung stören, wird die Entwicklung von TypeScript weniger angenehm sein.
Die Komplexität dieser Typendeklaration hat auch die Wahrscheinlichkeit erhöht, dass sich ein Fehler einschleicht. Zum Beispiel verlangt Expression4
, dass alle mathematischen Operatoren zwei Parameter annehmen, aber die Mapbox-Ausdrucksspezifikation sagt, dass +
und *
mehr Parameter annehmen können. Außerdem kann -
einen einzigen Parameter annehmen, in diesem Fall negiert er seine Eingabe. Expression4
zeigt in all diesen Fällen fälschlicherweise Fehler an:
const
okExpressions
:Expression4
[]
=
[
[
'-'
,
12
],
// ~~~ Type '"-"' is not assignable to type '"rgb"'.
[
'+'
,
1
,
2
,
3
],
// ~~~ Type '"+"' is not assignable to type '"rgb"'.
[
'*'
,
2
,
3
,
4
],
// ~~~ Type '"*"' is not assignable to type '"rgb"'.
];
Wieder einmal sind wir bei dem Versuch, genauer zu sein, über das Ziel hinausgeschossen und ungenau geworden. Diese Ungenauigkeiten können korrigiert werden, aber du solltest deine Tests ausweiten, um dich davon zu überzeugen, dass du nichts anderes übersehen hast. Komplexer Code erfordert in der Regel mehr Tests, und das Gleiche gilt für Typen.
Wenn du Typen verfeinerst, kann es hilfreich sein, an die Metapher des "unheimlichen Tals" zu denken. Die Verfeinerung von sehr ungenauen Typen wie any
ist normalerweise hilfreich. Aber je präziser die Typen werden, desto höher ist die Erwartung, dass sie auch genau sind. Du verlässt dich dann immer mehr auf die Typen, und Ungenauigkeiten führen zu größeren Problemen.
Dinge zum Erinnern
-
Vermeide das unheimliche Tal der Typsicherheit: Falsche Typen sind oft schlimmer als keine Typen.
-
Wenn du einen Typ nicht genau modellieren kannst, dann modelliere ihn nicht ungenau! Bestätige die Lücken mit
any
oderunknown
. -
Achte auf Fehlermeldungen und Autovervollständigung, wenn du die Eingaben immer genauer machst. Es geht nicht nur um Korrektheit, sondern auch um die Erfahrung der Entwickler.
Punkt 35: Typen aus APIs und Specs generieren, nicht aus Daten
Die anderen Artikel in diesem Kapitel haben die vielen Vorteile eines guten Typendesigns erörtert und gezeigt, was schiefgehen kann, wenn du es nicht tust. Ein gut gestalteter Typ macht die Verwendung von TypeScript zu einem Vergnügen, während ein schlecht gestalteter Typ die Verwendung miserabel machen kann. Das setzt das Typendesign allerdings ziemlich unter Druck. Wäre es nicht schön, wenn du das nicht selbst tun müsstest?
Zumindest einige deiner Typen werden wahrscheinlich von außerhalb deines Programms kommen: Dateiformate, APIs oder Spezifikationen. In diesen Fällen kannst du das Schreiben von Typen vielleicht vermeiden, indem du sie stattdessen generierst. In diesem Fall ist es wichtig, dass du Typen aus Spezifikationen und nicht aus Beispieldaten generierst. Wenn du Typen aus einer Spezifikation generierst, stellt TypeScript sicher, dass du keine Fälle übersehen hast. Wenn du Typen aus Daten generierst, berücksichtigst du nur die Beispiele, die du gesehen hast. Dabei könntest du wichtige Kanten übersehen, die dein Programm kaputt machen könnten.
In Punkt 31 haben wir eine Funktion geschrieben, um die Bounding Box eines GeoJSON Features zu berechnen. So sah das aus:
function
calculateBoundingBox
(
f
:GeoJSONFeature
)
:
BoundingBox
|
null
{
let
box
:BoundingBox
|
null
=
null
;
const
helper
=
(
coords
:any
[])
=>
{
// ...
};
const
{
geometry
}
=
f
;
if
(
geometry
)
{
helper
(
geometry
.
coordinates
);
}
return
box
;
}
Der Typ GeoJSONFeature
wurde nie explizit definiert. Du könntest ihn mit einigen der Beispiele aus Punkt 31 schreiben. Ein besserer Ansatz ist es jedoch, die formale GeoJSON-Spezifikation zu verwenden.1 Zum Glück für uns gibt es bereits TypeScript-Typendeklarationen dafür auf DefinitelyTyped. Du kannst sie auf die übliche Weise hinzufügen:
$ npm install --save-dev @types/geojson + @types/geojson@7946.0.7
Wenn du die GeoJSON-Deklarationen einfügst, zeigt TypeScript sofort einen Fehler an:
import
{
Feature
}
from
'geojson'
;
function
calculateBoundingBox
(
f
:Feature
)
:
BoundingBox
|
null
{
let
box
:BoundingBox
|
null
=
null
;
const
helper
=
(
coords
:any
[])
=>
{
// ...
};
const
{
geometry
}
=
f
;
if
(
geometry
)
{
helper
(
geometry
.
coordinates
);
// ~~~~~~~~~~~
// Property 'coordinates' does not exist on type 'Geometry'
// Property 'coordinates' does not exist on type
// 'GeometryCollection'
}
return
box
;
}
Das Problem ist, dass dein Code davon ausgeht, dass eine Geometrie eine coordinates
Eigenschaft hat. Das trifft auf viele Geometrien zu, darunter Punkte, Linien und Polygone. Aber eine GeoJSON-Geometrie kann auch eine GeometryCollection
sein, eine heterogene Sammlung von anderen Geometrien. Im Gegensatz zu den anderen Geometrietypen hat sie keine coordinates
Eigenschaft.
Wenn du calculateBoundingBox
für ein Feature aufrufst, dessen Geometrie GeometryCollection
ist, wird eine Fehlermeldung ausgegeben, dass die Eigenschaft 0
von undefined
nicht gelesen werden kann. Das ist ein echter Fehler! Und wir haben ihn mithilfe von Typdefinitionen aus einer Spezifikation gefunden.
Eine Möglichkeit, dies zu beheben, ist, GeometryCollection
s ausdrücklich zu verbieten, wie hier gezeigt:
const
{
geometry
}
=
f
;
if
(
geometry
)
{
if
(
geometry
.
type
===
'GeometryCollection'
)
{
throw
new
Error
(
'GeometryCollections are not supported.'
);
}
helper
(
geometry
.
coordinates
);
// OK
}
TypeScript ist in der Lage, den Typ von geometry
anhand der Prüfung zu verfeinern, sodass der Verweis auf geometry.coordinates
zulässig ist. Dies führt zumindest zu einer klareren Fehlermeldung für den Benutzer.
Aber die bessere Lösung ist, alle Arten von Geometrie zu unterstützen! Das kannst du erreichen, indem du eine weitere Hilfsfunktion herausziehst:
const
geometryHelper
=
(
g
:Geometry
)
=>
{
if
(
geometry
.
type
===
'GeometryCollection'
)
{
geometry
.
geometries
.
forEach
(
geometryHelper
);
}
else
{
helper
(
geometry
.
coordinates
);
// OK
}
}
const
{
geometry
}
=
f
;
if
(
geometry
)
{
geometryHelper
(
geometry
);
}
Hättest du die Typdeklarationen für GeoJSON selbst geschrieben, hättest du sie auf der Grundlage deiner Kenntnisse und Erfahrungen mit dem Format erstellt. Dies hätte möglicherweise GeometryCollection
nicht berücksichtigt und zu einem falschen Gefühl der Sicherheit bezüglich der Korrektheit deines Codes geführt. Die Verwendung von Typen, die auf einer Spezifikation basieren, gibt dir die Sicherheit, dass dein Code mit allen Werten funktioniert, nicht nur mit denen, die du kennst.
Ähnliche Überlegungen gelten für API-Aufrufe: Wenn du Typen aus der Spezifikation einer API generieren kannst, ist es in der Regel eine gute Idee, dies zu tun. Das funktioniert besonders gut bei APIs, die selbst typisiert sind, wie z. B. GraphQL.
Eine GraphQL-API wird mit einem Schema ausgeliefert, das alle möglichen Abfragen und Schnittstellen mit einem Typsystem ähnlich wie TypeScript spezifiziert. Du schreibst Abfragen, die bestimmte Felder in diesen Schnittstellen abfragen. Um zum Beispiel Informationen über ein Repository mit der GitHub GraphQL API zu erhalten, könntest du schreiben:
query { repository(owner: "Microsoft", name: "TypeScript") { createdAt description } }
Das Ergebnis ist:
{
"data"
:
{
"repository"
:
{
"createdAt"
:
"2014-06-17T15:28:39Z"
,
"description"
:
"TypeScript is a superset of JavaScript that compiles to JavaScript."
}
}
}
Das Schöne an diesem Ansatz ist, dass du TypeScript-Typen für deine spezifische Abfrage generieren kannst. Wie beim GeoJSON-Beispiel kannst du so sicherstellen, dass du die Beziehungen zwischen den Typen und ihre Nullbarkeit genau modellierst.
Hier ist eine Abfrage, um die Open-Source-Lizenz für ein GitHub-Repository zu erhalten:
query getLicense($owner:String!, $name:String!){ repository(owner:$owner, name:$name) { description licenseInfo { spdxId name } } }
$owner
und $name
sind GraphQL-Variablen, die selbst typisiert sind. Die Typsyntax ist TypeScript so ähnlich, dass es verwirrend sein kann, hin und her zu gehen. String
ist ein GraphQL-Typ - in TypeScript wäre es string
(siehe Punkt 10). Und während TypeScript-Typen nicht löschbar sind, sind Typen in GraphQL löschbar. Das !
hinter dem Typ zeigt an, dass er garantiert nicht null ist.
Es gibt viele Tools, die dir helfen, von einer GraphQL-Abfrage zu TypeScript-Typen zu gelangen. Eines davon ist Apollo. Hier erfährst du, wie du es benutzt:
$ apollo client:codegen \ --endpoint https://api.github.com/graphql \ --includes license.graphql \ --target typescript Loading Apollo Project Generating query files with 'typescript' target - wrote 2 files
Du brauchst ein GraphQL-Schema, um Typen für eine Abfrage zu erzeugen. Apollo holt es sich vom Endpunkt api.github.com/graphql
. Die Ausgabe sieht wie folgt aus:
export
interface
getLicense_repository_licenseInfo
{
__typename
:
"License"
;
/** Short identifier specified by <https://spdx.org/licenses> */
spdxId
:string
|
null
;
/** The license full name specified by <https://spdx.org/licenses> */
name
:string
;
}
export
interface
getLicense_repository
{
__typename
:
"Repository"
;
/** The description of the repository. */
description
:string
|
null
;
/** The license associated with the repository */
licenseInfo
:getLicense_repository_licenseInfo
|
null
;
}
export
interface
getLicense
{
/** Lookup a given repository by the owner and repository name. */
repository
:getLicense_repository
|
null
;
}
export
interface
getLicenseVariables
{
owner
:string
;
name
:string
;
}
Die wichtigsten Punkte sind hier zu beachten:
-
Schnittstellen werden sowohl für die Abfrageparameter (
getLicenseVariables
) als auch für die Antwort (getLicense
) erstellt. -
Die Informationen zur Löschbarkeit werden vom Schema an die Antwortschnittstellen übertragen. Die Felder
repository
,description
,licenseInfo
undspdxId
sind löschbar, während die Lizenzname
und die Abfragevariablen nicht löschbar sind. -
Die Dokumentation wird als JSDoc übertragen, damit sie in deinem Editor erscheint(Punkt 48). Diese Kommentare stammen aus dem GraphQL-Schema selbst.
Diese Typinformationen helfen dir, die API richtig zu nutzen. Wenn sich deine Abfragen ändern, werden sich auch die Typen ändern. Wenn sich das Schema ändert, ändern sich auch deine Typen. Es besteht keine Gefahr, dass deine Typen und die Realität auseinanderklaffen, da sie beide aus einer einzigen Quelle der Wahrheit stammen: dem GraphQL-Schema.
Was ist, wenn es keine Spezifikation oder kein offizielles Schema gibt? Dann musst du Typen aus Daten generieren. Tools wie quicktype
können dabei helfen. Aber sei dir bewusst, dass deine Typen möglicherweise nicht mit der Realität übereinstimmen: Es kann Kanten geben, die du übersehen hast.
Auch wenn du dir dessen nicht bewusst bist, profitierst du bereits von der Codegenerierung. TypeScripts Typdeklarationen für die Browser-DOM-API werden aus den offiziellen Schnittstellen generiert (siehe Punkt 55). Das stellt sicher, dass sie ein kompliziertes System korrekt modellieren und hilft TypeScript, Fehler und Missverständnisse in deinem eigenen Code zu erkennen.
Punkt 36: Benenne Typen in der Sprache deines Problembereichs
In der Informatik gibt es nur zwei schwierige Probleme: die Ungültigmachung von Caches und die Benennung von Dingen.
Phil Karlton
Dieses Buch hat viel über die Form von Typen und die Wertemengen in ihren Domänen gesagt, aber viel weniger darüber, wie du deine Typen benennst. Aber auch das ist ein wichtiger Teil des Typendesigns. Gut gewählte Typen-, Eigenschafts- und Variablennamen können die Absicht verdeutlichen und die Abstraktionsebene deines Codes und deiner Typen erhöhen. Schlecht gewählte Typen können deinen Code verwirren und zu falschen mentalen Modellen führen.
Angenommen, du baust eine Datenbank mit Tieren auf. Du erstellst eine Schnittstelle, die ein Tier repräsentiert:
interface
Animal
{
name
:string
;
endangered
:boolean
;
habitat
:string
;
}
const
leopard
:Animal
=
{
name
:
'Snow Leopard'
,
endangered
:false
,
habitat
:
'tundra'
,
};
Hier gibt es ein paar Probleme:
-
name
ist ein sehr allgemeiner Begriff. Was für einen Namen erwartest du? Einen wissenschaftlichen Namen? Einen gewöhnlichen Namen? -
Auch das boolesche Feld
endangered
ist nicht eindeutig. Was ist, wenn ein Tier vom Aussterben bedroht ist? Ist die Absicht hier "gefährdet oder schlimmer"? Oder bedeutet es wörtlich "gefährdet"? -
Das Feld
habitat
ist sehr zweideutig, nicht nur wegen des zu weit gefasstenstring
Typs(Punkt 33), sondern auch weil unklar ist, was mit "Lebensraum" gemeint ist. -
Der Variablenname ist
leopard
, aber der Wert der Eigenschaftname
ist "Snow Leopard". Ist diese Unterscheidung sinnvoll?
Hier ist eine Typdeklaration und ein Wert mit weniger Zweideutigkeit:
interface
Animal
{
commonName
:string
;
genus
:string
;
species
:string
;
status
:ConservationStatus
;
climates
:KoppenClimate
[];
}
type
ConservationStatus
=
'EX'
|
'EW'
|
'CR'
|
'EN'
|
'VU'
|
'NT'
|
'LC'
;
type
KoppenClimate
=
|
'Af'
|
'Am'
|
'As'
|
'Aw'
|
'BSh'
|
'BSk'
|
'BWh'
|
'BWk'
|
'Cfa'
|
'Cfb'
|
'Cfc'
|
'Csa'
|
'Csb'
|
'Csc'
|
'Cwa'
|
'Cwb'
|
'Cwc'
|
'Dfa'
|
'Dfb'
|
'Dfc'
|
'Dfd'
|
'Dsa'
|
'Dsb'
|
'Dsc'
|
'Dwa'
|
'Dwb'
|
'Dwc'
|
'Dwd'
|
'EF'
|
'ET'
;
const
snowLeopard
:Animal
=
{
commonName
:
'Snow Leopard'
,
genus
:
'Panthera'
,
species
:
'Uncia'
,
status
:
'VU'
,
// vulnerable
climates
:
[
'ET'
,
'EF'
,
'Dfd'
],
// alpine or subalpine
};
Das bringt eine Reihe von Verbesserungen mit sich:
-
name
wurde durch spezifischere Begriffe ersetzt:commonName
,genus
, undspecies
. -
endangered
iststatus
geworden, einConservationStatus
Typ, der ein Standardklassifizierungssystem der IUCN verwendet. -
habitat
istclimates
geworden und verwendet eine andere Standardtaxonomie, die Köppen-Klimaklassifikation.
Wenn du mehr Informationen über die Felder in der ersten Version dieses Typs brauchst, musst du die Person suchen, die sie geschrieben hat, und sie fragen. Höchstwahrscheinlich hat er die Firma verlassen oder erinnert sich nicht mehr. Schlimmer noch: Du könntest git blame
aufrufen, um herauszufinden, wer diese lausigen Typen geschrieben hat, nur um festzustellen, dass du es warst!
Mit der zweiten Version hat sich die Situation deutlich verbessert. Wenn du mehr über das Köppen-Klimaklassifizierungssystem erfahren oder herausfinden willst, was ein Schutzstatus genau bedeutet, findest du im Internet unzählige Ressourcen, die dir helfen.
Jeder Bereich hat ein spezielles Vokabular, um sein Thema zu beschreiben. Anstatt deine eigenen Begriffe zu erfinden, solltest du versuchen, Begriffe aus dem Bereich deines Problems zu verwenden. Diese Vokabeln wurden oft über Jahre, Jahrzehnte oder Jahrhunderte hinweg entwickelt und werden von den Fachleuten gut verstanden. Wenn du diese Begriffe verwendest, kannst du besser mit den Nutzern kommunizieren und die Klarheit deiner Texte erhöhen.
Achte darauf, dass du das Fachvokabular korrekt verwendest: Wenn du die Sprache eines Fachgebiets übernimmst, um etwas anderes zu meinen, ist das noch verwirrender, als wenn du dein eigenes erfindest.
Hier sind noch ein paar andere Regeln, die du beim Benennen von Typen, Eigenschaften undVariablen beachten solltest:
-
Mache Unterscheidungen sinnvoll. Beim Schreiben und Sprechen kann es ermüdend sein, immer wieder das gleiche Wort zu verwenden. Wir führen Synonyme ein, um die Monotonie zu durchbrechen. Das macht das Lesen von Prosa angenehmer, aber im Code hat es den gegenteiligen Effekt. Wenn du zwei verschiedene Begriffe verwendest, achte darauf, dass du eine sinnvolle Unterscheidung triffst. Wenn nicht, solltest du denselben Begriff verwenden.
-
Vermeide vage, nichtssagende Namen wie "Daten", "Info", "Ding", "Gegenstand", "Objekt" oder das allseits beliebte "Entität". Wenn "Entität" in deinem Bereich eine bestimmte Bedeutung hat, ist das in Ordnung. Wenn du ihn aber nur verwendest, weil dir kein aussagekräftigerer Name einfällt, wirst du irgendwann Probleme bekommen.
-
Benenne Dinge nach dem, was sie sind, und nicht nach dem, was sie enthalten oder wie sie berechnet werden.
Directory
ist aussagekräftiger alsINodeList
. Es ermöglicht dir, ein Verzeichnis als Konzept zu betrachten und nicht in Bezug auf seine Implementierung. Gute Namen erhöhen den Abstraktionsgrad und verringern das Risiko von ungewolltenKollisionen.
Punkt 37: Erwäge "Marken" für die Nominaltypisierung
Unter Punkt 4 ging es um strukturelle ("Ente") Typisierung und wie sie manchmal zu überraschenden Ergebnissen führen kann:
interface
Vector2D
{
x
:number
;
y
:number
;
}
function
calculateNorm
(
p
:Vector2D
)
{
return
Math
.
sqrt
(
p
.
x
*
p
.
x
+
p
.
y
*
p
.
y
);
}
calculateNorm
({
x
:3
,
y
:4
});
// OK, result is 5
const
vec3D
=
{
x
:3
,
y
:4
,
z
:1
};
calculateNorm
(
vec3D
);
// OK! result is also 5
Was, wenn du möchtest, dass calculateNorm
3D-Vektoren ablehnt? Das verstößt gegen das strukturelle Typisierungsmodell von TypeScript, ist aber mathematisch sicherlich korrekter.
Eine Möglichkeit, dies zu erreichen, ist die nominale Typisierung. Bei der Nominaltypisierung ist ein Wert ein Vector2D
, weil du es sagst, nicht weil er die richtige Form hat. Um dies in TypeScript anzunähern, kannst du eine "Marke" einführen (denk an Kühe, nicht an Coca-Cola):
interface
Vector2D
{
_brand
:
'2d'
;
x
:number
;
y
:number
;
}
function
vec2D
(
x
:number
,
y
:number
)
:
Vector2D
{
return
{
x
,
y
,
_brand
:
'2d'
};
}
function
calculateNorm
(
p
:Vector2D
)
{
return
Math
.
sqrt
(
p
.
x
*
p
.
x
+
p
.
y
*
p
.
y
);
// Same as before
}
calculateNorm
(
vec2D
(
3
,
4
));
// OK, returns 5
const
vec3D
=
{
x
:3
,
y
:4
,
z
:1
};
calculateNorm
(
vec3D
);
// ~~~~~ Property '_brand' is missing in type...
Die Marke stellt sicher, dass der Vektor von der richtigen Stelle stammt. Zugegeben, nichts hält dich davon ab, _brand: '2d'
zum Wert vec3D
hinzuzufügen. Aber hier wird aus dem Versehen eine bösartige Handlung. Diese Art von Branding reicht in der Regel aus, um den versehentlichen Missbrauch von Funktionen zu erkennen.
Interessanterweise kannst du viele der gleichen Vorteile wie explizite Marken nutzen, wenn du nur im Typsystem arbeitest. Dadurch entfällt der Laufzeit-Overhead und du kannst auch eingebaute Typen wie string
oder number
markieren, denen du keine zusätzlichen Eigenschaften zuweisen kannst.
Was ist zum Beispiel, wenn du eine Funktion hast, die auf dem Dateisystem arbeitet und einen absoluten (im Gegensatz zu einem relativen) Pfad benötigt? Das lässt sich zur Laufzeit leicht überprüfen (beginnt der Pfad mit "/"?), aber nicht so leicht im Typsystem.
Hier ist ein Ansatz mit Marken:
type
AbsolutePath
=
string
&
{
_brand
:
'abs'
};
function
listAbsolutePath
(
path
:AbsolutePath
)
{
// ...
}
function
isAbsolutePath
(
path
:string
)
:
path
is
AbsolutePath
{
return
path
.
startsWith
(
'/'
);
}
Du kannst kein Objekt konstruieren, das ein string
ist und eine _brand
Eigenschaft hat. Das ist ein reines Spiel mit dem Typensystem.
Wenn du einen string
Pfad hast, der entweder absolut oder relativ sein kann, kannst du ihn mit dem type guard überprüfen, der seinen Typ verfeinert:
function
f
(
path
:string
)
{
if
(
isAbsolutePath
(
path
))
{
listAbsolutePath
(
path
);
}
listAbsolutePath
(
path
);
// ~~~~ Argument of type 'string' is not assignable
// to parameter of type 'AbsolutePath'
}
Diese Art von Ansatz könnte hilfreich sein, um zu dokumentieren, welche Funktionen absolute oder relative Pfade erwarten und welche Art von Pfad jede Variable enthält. Es ist allerdings keine hundertprozentige Garantie: path as AbsolutePath
wird für jede string
erfolgreich sein. Aber wenn du diese Art von Aussagen vermeidest, ist die einzige Möglichkeit, eine AbsolutePath
zu bekommen, dass du sie bekommst oder dass du sie überprüfst, was genau das ist, was du willst.
Mit diesem Ansatz lassen sich viele Eigenschaften modellieren, die nicht im Typsystem ausgedrückt werden können. Zum Beispiel die binäre Suche, um ein Element in einer Liste zu finden:
function
binarySearch
<
T
>
(
xs
:T
[],
x
:T
)
:
boolean
{
let
low
=
0
,
high
=
xs
.
length
-
1
;
while
(
high
>=
low
)
{
const
mid
=
low
+
Math
.
floor
((
high
-
low
)
/
2
);
const
v
=
xs
[
mid
];
if
(
v
===
x
)
return
true
;
[
low
,
high
]
=
x
>
v
?
[
mid
+
1
,
high
]
:
[
low
,
mid
-
1
];
}
return
false
;
}
Das funktioniert, wenn die Liste sortiert ist, führt aber zu falsch-negativen Ergebnissen, wenn sie es nicht ist. Du kannst eine sortierte Liste nicht im Typsystem von TypeScript darstellen. Aber du kannst eine Marke erstellen:
type
SortedList
<
T
>
=
T
[]
&
{
_brand
:
'sorted'
};
function
isSorted
<
T
>
(
xs
:T
[])
:
xs
is
SortedList
<
T
>
{
for
(
let
i
=
1
;
i
<
xs
.
length
;
i
++
)
{
if
(
xs
[
i
]
<
xs
[
i
-
1
])
{
return
false
;
}
}
return
true
;
}
function
binarySearch
<
T
>
(
xs
:SortedList
<
T
>
,
x
:T
)
:
boolean
{
// ...
}
Um diese Version von binarySearch
aufzurufen, musst du entweder eine SortedList
bekommen (d.h. einen Beweis haben, dass die Liste sortiert ist) oder selbst mit isSorted
beweisen, dass sie sortiert ist. Der lineare Scan ist zwar nicht toll, aber wenigstens bist du sicher!
Dies ist eine hilfreiche Perspektive für die Typüberprüfung im Allgemeinen. Um zum Beispiel eine Methode für ein Objekt aufzurufen, musst du entweder ein Objekt erhalten, das nichtnull
ist, oder selbst mit einer Bedingung beweisen, dass es nichtnull
ist.
Du kannst auch number
Typen markieren - zum Beispiel, um Einheiten anzubringen:
type
Meters
=
number
&
{
_brand
:
'meters'
};
type
Seconds
=
number
&
{
_brand
:
'seconds'
};
const
meters
=
(
m
:number
)
=>
m
as
Meters
;
const
seconds
=
(
s
:number
)
=>
s
as
Seconds
;
const
oneKm
=
meters
(
1000
);
// Type is Meters
const
oneMin
=
seconds
(
60
);
// Type is Seconds
Das kann in der Praxis unangenehm sein, denn durch Rechenoperationen vergessen die Zahlen ihre Marken:
const
tenKm
=
oneKm
*
10
;
// Type is number
const
v
=
oneKm
/
oneMin
;
// Type is number
Wenn dein Code jedoch viele Zahlen mit gemischten Einheiten enthält, kann dies immer noch ein attraktiver Ansatz sein, um die erwarteten Arten von numerischen Parametern zu dokumentieren.
Dinge zum Erinnern
-
TypeScript verwendet die strukturelle Typisierung ("Duck"), die manchmal zu überraschenden Ergebnissen führen kann. Wenn du nominale Typisierung brauchst, solltest du deine Werte mit "Marken" versehen, um sie zu unterscheiden.
-
In manchen Fällen ist es möglich, Marken vollständig im Typsystem und nicht zur Laufzeit zuzuordnen. Du kannst diese Technik verwenden, um Eigenschaften außerhalb des TypeScript-Typensystems zu modellieren.
1 GeoJSON ist auch als RFC 7946 bekannt. Die sehr lesenswerte Spezifikation findest du unter http://geojson.org.
Get Effektives TypeScript 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.