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, The Mythical Man Month (Addison-Wesley Professional)

Die Formulierung 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.

Andere Kapitel behandeln die Grundlagen von TypeScript-Typen: ihre Verwendung, ihre Ableitung, ihre Umwandlung und das Schreiben von Deklarationen mit ihnen. 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 29: 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, können dich weder Cleverness noch Dokumentation retten. Dein Code wird verwirrend und anfällig für Fehler 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 einige 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 auf false zu setzen.

  • Wir haben state.error nicht gelöscht. Wenn also die vorherige Anfrage fehlgeschlagen ist, siehst du weiterhin diese Fehlermeldung statt einer Ladebestätigung oder der neuen Seite.

  • 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 einen Fehler, 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 sowohlrender() 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, in denen sich eine Netzwerkanfrage befinden 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 wechselt, 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 gestellt, ob es klug ist, sich bei Entscheidungen über Leben und Tod auf Computer zu verlassen. Als zwei Jahre später die Flugschreiber vom Meeresgrund geborgen wurden, enthüllten sie viele Faktoren, die zu dem Absturz führten. Ein Schlüsselfaktor war ein schlechtes Design des Staates.

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 mit dieser Implementierung: Wir können nur dann sicher sein, dass die linke Einstellung zurückgegeben wird, wenn die rechte neutral ist. Das solltest du 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 machen? Nicht wirklich: Die Flügelklappen 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 jetzt, 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

  • Typen, die sowohl gültige als auch ungültige Zustände darstellen, führen wahrscheinlich zu verwirrendem und fehleranfälligem Code.

  • Bevorzuge Typen, die nur gültige Zustände darstellen. Auch wenn sie länger oder schwieriger auszudrücken sind, ersparen sie dir auf Zeit und Mühe!

Punkt 30: Sei liberal in dem, was du akzeptierst und streng in dem, was du produzierst

Diese Idee ist bekannt als das Robustheitsprinzip oder Postel's Law, nach Jon Postel, der es im Zusammenhang mit dem TCP-Netzwerkprotokoll 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 lat/lngs 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 (3 × 3 + 3 × 3 + 1). Wirklich liberal!

Schreiben wir nun eine Funktion, die den Viewport an ein GeoJSON-Feature anpasst und den neuen Viewport in der URL speichert (wir gehen davon aus, dass wir eine Hilfsfunktion haben, um die Bounding Box eines GeoJSON-Features zu berechnen):

function focusOnFeature(f: Feature) {
  const bounds = calculateBoundingBox(f); // helper function
  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;
  // ^? const zoom: 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 sieproduziert, liberal ist. Der einzige typsichere Weg, das Ergebnis von camera zu verwenden, besteht darin, für jede Komponente des Union-Typs einen Code-Zweig einzuführen.

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 17) 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 den strengeren Camera Typ an. Die Verwendung von Partial​<Cam⁠era> 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 "Camera​Op⁠tions extends Partial<Camera>" schreiben, da LngLatLike ein Supertyp von LngLat ist, kein Subtyp. (Wenn dir das falsch vorkommt, kannst du unter Punkt 7 noch einmal nachlesen).

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
  //                         ^? const zoom: 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!

Eine der häufigsten Anwendungen dieses Musters ist bei Funktionen, die Arrays als Parameter annehmen. Hier ist zum Beispiel eine Funktion, die die Elemente eines Arrays summiert:

function sum(xs: number[]): number {
  let sum = 0;
  for (const x of xs) {
    sum += x;
  }
  return sum;
}

Die Rückgabeart von number ist ziemlich streng. Toll! Aber was ist mit dem Parametertyp von number[]? Wir nutzen nicht viele seiner Möglichkeiten, also könnte er lockerer sein. In Punkt 17 wurde der Typ ArrayLike besprochen, und ArrayLike<number> würde hier gut funktionieren. In Artikel 14 ging es um readonly Arrays, und readonly number[] würde sich auch gut als Parametertyp eignen.

Wenn du aber nur über den Parameter iterieren musst, dann ist Iterable der breiteste Typ von allen:

function sum(xs: Iterable<number>): number {
  let sum = 0;
  for (const x of xs) {
    sum += x;
  }
  return sum;
}

Das funktioniert so, wie du es von einem Array erwartest:

const six = sum([1, 2, 3]);
//    ^? const six: number

Der Vorteil der Verwendung von Iterable anstelle von Array oder ArrayLike ist, dass sie auch Generatorausdrücke zulässt:

function* range(limit: number) {
  for (let i = 0; i < limit; i++) {
    yield i;
  }
}
const zeroToNine = range(10);
//    ^? const zeroToNine: Generator<number, void, unknown>
const fortyFive = sum(zeroToNine);  // ok, result is 45

Wenn deine Funktion nur über ihre Parameter iterieren muss, kannst du Iterable verwenden, damit sie auch mit Generatoren funktioniert. Wenn du for-of Schleifen verwendest, brauchst du keine einzige Zeile deines Codes zu ändern.

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.

  • Vermeide breite Rückgabearten, da diese für die Kunden umständlich zu bedienen sind.

  • 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.

  • Verwende Iterable<T> anstelle von T[], wenn du nur über deinen Funktionsparameter iterieren musst.

Punkt 31: Keine Wiederholung von Typinformationenin 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 wurde so konzipiert, dass es kompakt, anschaulich 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 statt in die Dokumentation einträgst, kannst du dich darauf verlassen, dass sie auch dann noch korrekt sind, wenn sich der Code weiterentwickelt.

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 68.

Kommentare über einen Mangel an Mutation sind ebenfalls verdächtig:

/** Sort the strings by numeric value (i.e. "2" < "10"). Does not modify nums. */
function sortNumerically(nums: string[]): string[] {
  return nums.sort((a, b) => Number(a) - Number(b));
}

Der Kommentar sagt, dass diese Funktion ihren Parameter nicht verändert, aber die sort Methode für Arrays arbeitet an Ort und Stelle, also tut sie das sehr wohl. Behauptungen in Kommentaren sind nicht viel wert.

Wenn du stattdessen den Parameter readonly deklarierst(Punkt 14), kannst du den Vertrag von TypeScript erzwingen lassen:

/** Sort the strings by numeric value (i.e. "2" < "10"). */
function sortNumerically(nums: readonly string[]): string[] {
  return nums.sort((a, b) => Number(a) - Number(b));
  //          ~~~~  ~  ~ Property 'sort' does not exist on 'readonly string[]'.
}

Eine korrekte Implementierung dieser Funktion würde entweder das Array kopieren oder die unveränderliche Methode toSorted verwenden:

/** Sort the strings by numeric value (i.e. "2" < "10"). */
function sortNumerically(nums: readonly string[]): string[] {
  return nums.toSorted((a, b) => Number(a) - Number(b));  // ok
}

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. In Artikel 64 werden "Marken" beschrieben, 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 von Typdeklarationen und im schlimmsten Fall führt es zu widersprüchlichen Informationen.

  • Deklariere Parameter readonly, anstatt zu sagen, dass du sie nicht veränderst.

  • Erwäge, Einheiten in die Variablennamen aufzunehmen, wenn sie aus nicht eindeutig hervorgehen (z. B. timeMs oder temperatureC).

Punkt 32: Vermeide die Aufnahme von null oder undefiniert in Typ-Aliase

In diesem Code, ist die optionale Kette (?.) notwendig? Kann user überhaupt null sein?

function getCommentsForUser(comments: readonly Comment[], user: User) {
  return comments.filter(comment => comment.userId === user?.id);
}

Selbst wenn du strictNullChecks annimmst, ist es unmöglich, das zu sagen, ohne die Definition von User zu kennen. Wenn es sich um einen Typ-Alias handelt, der null oder undefined erlaubt, dann wird die optionale Kette benötigt:

type User = { id: string; name: string; } | null;

Wenn es hingegen ein einfacher Objekttyp ist, dann nicht:

interface User {
  id: string;
  name: string;
}

In der Regel ist es besser, Typ-Aliase zu vermeiden, die null oder undefined Werte zulassen. Der Typprüfer wird zwar nicht verwirrt, wenn du gegen diese Regel verstößt, aber die menschlichen Leser deines Codes schon. Wenn wir einen Typnamen wie User lesen, gehen wir davon aus, dass er für einen Benutzer steht und nicht vielleicht für einen Benutzer.

Wenn du aus irgendeinem Grund null in einen Typ-Alias aufnehmen musst, tue den Lesern deines Codes einen Gefallen und benutze einen eindeutigen Namen:

type NullableUser = { id: string; name: string; } | null;

Aber warum sollte man das tun, wenn User|null eine prägnantere und allgemein erkennbareSyntax ist?

function getCommentsForUser(comments: readonly Comment[], user: User | null) {
  return comments.filter(comment => comment.userId === user?.id);
}

Diese Regel bezieht sich auf die oberste Ebene der Typ-Aliase. Sie befasst sich nicht mit einer null oderundefined (oder optionale) Eigenschaft in einem größeren Objekt:

type BirthdayMap = {
  [name: string]: Date | undefined;
};

Tu das einfach nicht:

type BirthdayMap = {
  [name: string]: Date | undefined;
} | null;

Es gibt auch Gründe, null Werte und optionale Felder in Objekttypen zu vermeiden, aber das ist ein Thema für die Punkte 33 und 37. Vermeide zunächst Typ-Aliase, die für die Leser deines Codes verwirrend sind. Bevorzuge Typ-Aliase, die etwas repräsentieren, anstatt etwas oder null oder undefined zu repräsentieren.

Dinge zum Erinnern

  • Vermeide es, Typ-Aliase zu definieren, die null oder undefined enthalten.

Punkt 33: 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:

// @strictNullChecks: false
function extent(nums: Iterable<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 mehreren undefinedist für Kunden schwer zu handhaben und ist genau die Art von Typ, von der dieser Artikel abrät. Wenn wir den Quellcode lesen, wissen wir, dass entweder min und max beide undefined sein werden oder keines von beiden, aber diese Information ist nicht im Typensystem enthalten.

Wenn du strictNullChecks einschaltest, wird das Problem mit undefined noch deutlicher:

function extent(nums: Iterable<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, min und max in dasselbe Objekt zu packen und dieses Objekt entweder vollständig null oder vollständig nichtnull zu machen:

function extent(nums: Iterable<number>) {
  let minMax: [number, number] | null = null;
  for (const num of nums) {
    if (!minMax) {
      minMax = [num, num];
    } else {
      const [oldMin, oldMax] = minMax;
      minMax = [Math.min(num, oldMin), Math.max(num, oldMax)];
    }
  }
  return minMax;
}

Der Rückgabetyp ist jetzt [number, number] | null, was für Kunden einfacher zu handhaben ist. min und max können entweder mit einer Nicht-Null-Assertion abgerufen 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
}

Indem wir ein einziges Objekt verwenden, um das Ausmaß zu verfolgen, haben wir unser Design verbessert, TypeScript geholfen, die Beziehung zwischen Nullwerten zu verstehen, und den Fehler behoben: Dieif (!minMax) Prüfung ist jetzt problemlos möglich.

(Ein nächster Schritt könnte darin bestehen, die Übergabe von nicht leeren Listen an extent zu verhindern, wodurch die Möglichkeit, null zurückzugeben, gänzlich wegfallen würde. In Punkt 64 wird eine Möglichkeit vorgestellt, wie du eine nicht leere Liste im Typsystem von TypeScript darstellen kannst).

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 darstellt:

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 von der Klasse verwendeten Daten 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 Multiplizität 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 machen den Code, der Daten lädt, übersichtlicher, 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 nicht null ist, implizit mit einem anderen Wert, der null oder nicht null ist, verbunden ist.

  • Schiebe die Werte von null an den Rand deiner API, indem du größere Objekte entweder zu null 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 nichtnull Klasse zu erstellen und sie zu konstruieren, wenn alle Werte verfügbar sind.

Punkt 34: Unionen von Schnittstellen gegenüberSchnittstellen mit Unionen bevorzugen

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?).

Die Absicht ist, dass ein Layer mit layout und paint übereinstimmt. Ein FillLayout sollte zu einem FillPaint passen, und ein LineLayout sollte zu einem LinePaint passen. Aber diese Version des Typs Layer erlaubt auch ein FillLayout mit einem LinePaint. Diese Möglichkeit 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 von Artikel 29befolgst, Typen zu bevorzugen, die nur gültige Zustände repräsentieren.

Das mit Abstand 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" oder die "Diskriminante". Sie kann zur Laufzeit aufgerufen werden und gibt TypeScript gerade genug Informationen, um zu bestimmen, mit welchem Element des Union-Typs du arbeitest. Hier kann TypeScript den Typ von Layer in einer if Anweisung anhand des Tags eingrenzen:

function drawLayer(layer: Layer) {
  if (layer.type === 'fill') {
    const {paint} = layer;
    //     ^? const paint: FillPaint
    const {layout} = layer;
    //     ^? const layout: FillLayout
  } else if (layer.type === 'line') {
    const {paint} = layer;
    //     ^? const paint: LinePaint
    const {layout} = layer;
    //     ^? const layout: LineLayout
  } else {
    const {paint} = layer;
    //     ^? const paint: PointPaint
    const {layout} = layer;
    //     ^? const layout: 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 undefined vorstellst, dann entsprechen sie auch dem Muster "Schnittstelle von Vereinigungen". Betrachte diesen Typ:

interface Person {
  name: string;
  // These will either both be present or not be present
  placeOfBirth?: string;
  dateOfBirth?: Date;
}

Wie in Punkt 31 erläutert, ist der Kommentar mit den Typinformationen ein deutliches Zeichen dafür, dass es ein Problem geben könnte. 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 33):

interface Person {
  name: string;
  birth?: {
    place: string;
    date: Date;
  }
}

Jetzt beschwert sich TypeScript ü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(person: Person) {
  console.log(person.name);
  const {birth} = person;
  if (birth) {
    console.log(`was born on ${birth.date} in ${birth.place}.`);
  }
}

Wenn die Struktur des Typs außerhalb deiner Kontrolle liegt (vielleicht kommt sie von einer API), kannst du die Beziehung zwischen diesen Feldern immer noch mit der 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(person: Person) {
  if ('placeOfBirth' in person) {
    person
    // ^? (parameter) person: PersonWithBirth
    const {dateOfBirth} = person;  // OK
    //     ^? const dateOfBirth: Date
  }
}

In beiden Fällen macht die Typdefinition die Beziehung zwischen den Eigenschaften deutlicher.

Obwohl optionale Eigenschaften oft nützlich sind, solltest du es dir zweimal überlegen, bevor du sie zu einer Schnittstelle hinzufügst. In Artikel 37 werden die Nachteile von optionalen Feldern näher erläutert.

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.

  • Verwende getaggte Unions, um die Kontrollflussanalyse zu erleichtern. Weil sie so gut unterstützt werden, ist dieses Muster in TypeScript-Code allgegenwärtig.

  • Überlege, ob du mehrere optionale Eigenschaften zusammenfassen kannst, um deine Daten genauer zu modellieren .

Artikel 35: Präzisere Alternativen zu String-Typen bevorzugen

Erinnere dich an aus Punkt 7, dass der Bereich eines Typs die Menge der Werte ist, die diesem Typ zugeordnet werden können. Die Domäne des Typs string ist riesig: "x" und "y" gehören dazu, 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(Punkt 31) sind deutliche Hinweise darauf, dass diese interface nicht ganz richtig ist. Folgendes kann schief gehen:

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 (gemäß dem Kommentar) und'Studio' wird großgeschrieben, obwohl es kleingeschrieben sein sollte. Aber diese Werte sind beides Zeichenketten, so dass dieses Objekt Album zugewiesen werden kann und die Typprüfung sich nicht beschwert.

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.(In Artikel 38 wird erläutert, dass wiederholte Positionsparameter beliebigen Typs problematisch sein können, nicht nur string.)

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, 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 72):

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 durch 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 einfach 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 68):

/** 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).

ets2 0401
Abbildung 4-1. Wenn du einen benannten Typ anstelle von string verwendest, kannst du die Dokumentation an den Typ anhängen, der in deinem Editor angezeigt wird.

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 abruft. Die Utility-Bibliotheken Underscore und Ramda nennen dies 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 43). 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 Typ string 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 K = keyof Album
//      (equivalent to "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');
//    ^? const releaseDates: (string | Date)[]

Der Typ sollte Date[] sein, nicht (string | Date)[]. keyof T ist zwar viel enger als string, aber immer noch zu weit gefasst. Um ihn weiter einzugrenzen, müssen wir einen zweiten Typparameter einführen, der ein Subtyp von keyof T ist (wahrscheinlich ein Einzelwert):

function pluck<T, K extends keyof T>(records: T[], key: K): T[K][] {
  return records.map(r => r[key]);
}

Die Typsignatur ist jetzt völlig korrekt. Wir können das überprüfen, indem wir pluck auf verschiedene Arten aufrufen:

const dates = pluck(albums, 'releaseDate');
//    ^? const dates: Date[]
const artists = pluck(albums, 'artist');
//    ^? const artists: string[]
const types = pluck(albums, 'recordingType');
//    ^? const types: RecordingType[]
const mix = pluck(albums, Math.random() < 0.5 ? 'releaseDate' : 'artist');
//    ^? const mix: (string | Date)[]
const badDates = pluck(albums, 'recordingDate');
//                             ~~~~~~~~~~~~~~~
// Argument of type '"recordingDate"' is not assignable to parameter of type ...

Der Sprachendienst ist sogar in der Lage, eine Autovervollständigung auf den Tasten von Album anzubieten (siehe Abbildung 4-2).

ets2 0402
Abbildung 4-2. Die Verwendung des Parametertyps keyof Album anstelle von string führt zu einer besseren Autovervollständigung in deinem Editor.

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.

In diesem Artikel ging es um endliche Mengen von string, aber mit TypeScript kannst du auch unendliche Mengen modellieren, zum Beispiel alle string, die mit "http:" beginnen. Hierfür solltest du Templating-Literal-Typen verwenden, die in Artikel 54 behandelt werden.

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über string für Funktionsparameter, von denen erwartet wird, dass sie Eigenschaften eines Objekts sind.

Punkt 36: Einen eigenen Typ für besondere Werte verwenden

Die JavaScript-Methode string split ist eine praktische Methode, um eine Zeichenkette um ein Begrenzungszeichen herum zu trennen:

> 'abcde'.split('c')
[ 'ab', 'de' ] 

Lass uns etwas wie split schreiben, aber für Arrays. Hier ist ein Versuch:

function splitAround<T>(vals: readonly T[], val: T): [T[], T[]] {
  const index = vals.indexOf(val);
  return [vals.slice(0, index), vals.slice(index+1)];
}

Das funktioniert so, wie du es erwartet hast:

> splitAround([1, 2, 3, 4, 5], 3)
[ [ 1, 2 ], [ 4, 5 ] ]

Wenn du jedoch versuchst, ein Element zu splitAround, das nicht in der Liste ist, tut es etwas ganz Unerwartetes:

> splitAround([1, 2, 3, 4, 5], 6)
[ [ 1, 2, 3, 4 ], [ 1, 2, 3, 4, 5 ] ]

Es ist zwar nicht ganz klar, was die Funktion in diesem Fall tun soll, aber das ist sie definitiv nicht! Wie konnte ein so einfacher Code zu einem so seltsamen Verhalten führen?

Das Hauptproblem ist, dass indexOf -1 zurückgibt, wenn es das Element in dem Array nicht finden kann. Das ist ein besonderer Wert: Er zeigt einen Fehler und keinen Erfolg an. Aber -1 ist nur ein gewöhnlicher number. Du kannst ihn an die Methode Array slice übergeben und mit ihm rechnen. Wenn du eine negative Zahl an slice übergibst, wird sie als Rückwärtszählung vom Ende des Arrays interpretiert. Und wenn du 1 zu -1 addierst, erhältst du 0. Die Auswertung lautet also:

[vals.slice(0, -1), vals.slice(0)]

Die erste slice gibt alle Elemente bis auf das letzte Element des Arrays zurück und die zweite slice gibt eine vollständige Kopie des Arrays zurück.

Dieses Verhalten ist ein Fehler. Außerdem ist es bedauerlich, dass TypeScript nicht in der Lage war, uns bei der Suche nach diesem Problem zu helfen. Das eigentliche Problem war, dass indexOf -1 zurückgab, wenn es das Element nicht finden konnte, und nicht etwa null. Warum ist das so?

Ohne in eine Zeitmaschine zu springen und die Netscape-Büros im Jahr 1995 zu besuchen, ist es schwer, die Antwort mit Sicherheit zu wissen. Aber wir können spekulieren! JavaScript wurde stark von Java beeinflusst, und auch indexOf hat dieses Verhalten. In Java (und C) kann eine Funktion keine Primitive oder Null zurückgeben. Nur Objekte (oder Zeiger) sind nullbar. Dieses Verhalten könnte also auf eine technische Einschränkung in Java zurückzuführen sein, die JavaScript nicht hat.

In JavaScript (und TypeScript) ist es kein Problem, wenn eine Funktion ein number oder null zurückgibt. Wir können also indexOf einpacken:

function safeIndexOf<T>(vals: readonly T[], val: T): number | null {
  const index = vals.indexOf(val);
  return index === -1 ? null : index;
}

Wenn wir das in unsere ursprüngliche Definition von splitAround einfügen, erhalten wir sofort zwei Typenfehler:

function splitAround<T>(vals: readonly T[], val: T): [T[], T[]] {
  const index = safeIndexOf(vals, val);
  return [vals.slice(0, index), vals.slice(index+1)];
  //                    ~~~~~              ~~~~~ 'index' is possibly 'null'
}

Das ist genau das, was wir wollen! Bei indexOf gibt es immer zwei Fälle zu beachten. In der eingebauten Version kann TypeScript nicht zwischen ihnen unterscheiden, aber in der gewickelten Version schon. Und es sieht hier, dass wir nur den Fall betrachtet haben, in dem das Array den Wert enthält.

Die Lösung ist, den anderen Fall explizit zu behandeln:

function splitAround<T>(vals: readonly T[], val: T): [T[], T[]] {
  const index = safeIndexOf(vals, val);
  if (index === null) {
    return [[...vals], []];
  }
  return [vals.slice(0, index), vals.slice(index+1)];  // ok
}

Ob dies das richtige Verhalten ist, darüber lässt sich streiten, aber zumindest hat TypeScript uns gezwungen, diese Debatte zu führen!

Das Hauptproblem bei der ersten Implementierung war, dass indexOf zwei verschiedene Fälle hatte, aber der Rückgabewert im Sonderfall (-1) hatte denselben Typ wie der Rückgabewert im regulären Fall (number). Das bedeutete, dass es aus der Sicht von TypeScript nur einen einzigen Fall gab, und es war nicht in der Lage zu erkennen, dass wir nicht auf -1 geprüft hatten.

Diese Situation tritt häufig auf, wenn du Typen entwirfst. Vielleicht hast du einen Typ zur Beschreibung von Waren:

interface Product {
  title: string;
  priceDollars: number;
}

Dann stellst du fest, dass einige Produkte einen unbekannten Preis haben. Dieses Feld optional zu machen oder es auf number|null zu ändern, könnte eine Migration und viele Codeänderungen erfordern:

interface Product {
  title: string;
  /** Price of the product in dollars, or -1 if price is unknown */
  priceDollars: number;
}

Du schickst es in die Produktion. Eine Woche später ist dein Chef wütend und will wissen, warum du Geld auf Kundenkarten gutgeschrieben hast. Dein Team arbeitet daran, die Änderung zurückzunehmen, und du sollst den Bericht darüber schreiben. Im Nachhinein betrachtet wäre es viel einfacher gewesen, mit diesen Tippfehlern umzugehen!

Die Auswahl von domäneninternen Sonderwerten wie -1, 0 oder "" ist ähnlich sinnvoll wie das Ausschalten von strictNullChecks. Wenn strictNullChecks ausgeschaltet ist, kannst du null oderundefined einem beliebigen Typ zuordnen:

// @strictNullChecks: false
const truck: Product = {
  title: 'Tesla Cybertruck',
  priceDollars: null,  // ok
};

Dadurch schlüpft eine große Anzahl von Fehlern durch den Typprüfer, weil TypeScript nicht zwischen number und number|null unterscheidet. null ist ein gültiger Wert in allen Typen. Wenn du strictNullChecks aktivierst , unterscheidet TypeScript zwischen diesen Typen und kann eine ganze Reihe von neuen Problemen erkennen. Wenn du einen domäneninternen Spezialwert wie -1 wählst, schaffst du dir eine nicht-strikte Nische in deinen Typen. Das ist zwar praktisch, aber letztlich nicht die beste Wahl.

null und undefined sind nicht immer der richtige Weg, um Sonderfälle darzustellen, da ihre genaue Bedeutung kontextabhängig sein kann. Wenn du z. B. den Status einer Netzwerkanfrage modellierst, wäre es keine gute Idee, null für einen Fehlerstatus und undefined für einen ausstehenden Status zu verwenden. Besser ist es, eine getaggte Union zu verwenden, um diese speziellen Zustände explizit darzustellen. Unter Punkt 29 wird dieses Beispiel näher erläutert.

Dinge zum Erinnern

  • Vermeide spezielle Werte, die regulären Werten in einem Typ zugewiesen werden können. Sie verringern die Fähigkeit von TypeScript, Fehler in deinem Code zu finden.

  • Bevorzuge null oder undefined als Sonderwert anstelle von 0, -1 oder "".

  • Ziehe in Erwägung, anstelle von null oder undefined eine getaggte Union zu verwenden, wenn die Bedeutung dieser Werte nicht klar ist.

Punkt 37: Beschränkung der Verwendung von optionalen Eigenschaften

Wenn sich deine Typen weiterentwickeln, wirst du unweigerlich neue Eigenschaften zu ihnen hinzufügen wollen. Um zu vermeiden, dass bestehender Code oder Daten ungültig werden, kannst du diese Eigenschaften optional machen. Das ist zwar manchmal die richtige Entscheidung, aber optionale Eigenschaften haben ihren Preis und du solltest dir zweimal überlegen, ob du sie hinzufügen willst.

Stell dir vor, du hast eine UI-Komponente, die Zahlen mit einer Beschriftung und Einheiten anzeigt. Denke an "Höhe: 12 ft" oder "Geschwindigkeit: 10 mph":

interface FormattedValue {
  value: number;
  units: string;
}
function formatValue(value: FormattedValue) { /* ... */ }

Du baust eine große Webanwendung mit dieser Komponente. Vielleicht zeigt ein Teil davon formatierte Informationen über eine Wanderung an, die du unternommen hast ("5 miles at 2 mph"):

interface Hike {
  miles: number;
  hours: number;
}
function formatHike({miles, hours}: Hike) {
  const distanceDisplay = formatValue({value: miles, units: 'miles'});
  const paceDisplay = formatValue({value: miles / hours, units: 'mph'});
  return `${distanceDisplay} at ${paceDisplay}`;
}

Eines Tages lernst du das metrische System kennen und beschließt, es zu unterstützen. Um sowohl das metrische als auch das imperiale System zu unterstützen, fügst du eine entsprechende Option zu FormattedValue hinzu. Wenn nötig, führt die Komponente eine Einheitenumrechnung durch, bevor sie den Wert anzeigt. Um die Änderungen am bestehenden Code und an den Tests zu minimieren, beschließt du, die Eigenschaft optional zu machen:

type UnitSystem = 'metric' | 'imperial';
interface FormattedValue {
  value: number;
  units: string;
  /** default is imperial */
  unitSystem?: UnitSystem;
}

Damit der Benutzer dies konfigurieren kann, wollen wir auch ein Einheitensystem in unserer app-weiten Konfiguration angeben:

interface AppConfig {
  darkMode: boolean;
  // ... other settings ...
  /** default is imperial */
  unitSystem?: UnitSystem;
}

Jetzt können wir formatHike aktualisieren, um das metrische System zu unterstützen:

function formatHike({miles, hours}: Hike, config: AppConfig) {
  const { unitSystem } = config;
  const distanceDisplay = formatValue({
    value: miles, units: 'miles', unitSystem
  });
  const paceDisplay = formatValue({
    value: miles / hours, units: 'mph'  // forgot unitSystem, oops!
  });
  return `${distanceDisplay} at ${paceDisplay}`;
}

Wir haben unitSystem in einem Aufruf auf formatValue gesetzt, aber nicht im anderen. Das ist ein Fehler, der bedeutet, dass unsere metrischen Nutzer eine Mischung aus imperialen und metrischen Einheiten sehen.

Tatsächlich ist unser Design ein Rezept für genau diese Art von Fehler. An jeder Stelle, an der wir die Komponente formatValue verwenden, müssen wir uns daran erinnern, eine unitSystem zu übergeben. Wenn wir das nicht tun, werden metrische Benutzer verwirrende imperiale Einheiten wie Yards, Acres oder Foot-Pounds sehen.

Es wäre schön, wenn es eine Möglichkeit gäbe, automatisch alle Stellen zu finden, an denen wir vergessen haben, eine unitSystem zu übergeben. Das ist genau die Art von Dingen, für die die Typüberprüfung gut ist, aber wir haben sie davon abgehalten, uns zu helfen, indem wir die unitSystem Eigenschaft optional gemacht haben.

Wenn du sie stattdessen als erforderlich definierst, bekommst du überall dort einen Typfehler, wo du vergessen hast, sie zu setzen. Du musst diese Fehler nach und nach beheben, aber es ist viel besser, wenn TypeScript diese Fehler findet, als von verwirrten Benutzern darüber zu hören!

Der Dokumentationskommentar "Standardwert ist kaiserlich" ist ebenfalls besorgniserregend. In TypeScript ist der Standardwert für eine optionale Eigenschaft eines Objekts immer undefined. Um eine alternative Voreinstellung zu implementieren, wird unser Code wahrscheinlich mit Zeilen wie dieser übersät sein:

declare let config: AppConfig;
const unitSystem = config.unitSystem ?? 'imperial';

Jede dieser Möglichkeiten ist eine Chance für einen Fehler. Vielleicht vergisst ein anderer Entwickler in deinem Team, dass das britische System der Standard ist (warum ist es überhaupt der Standard?) und nimmt an, dass es metrisch sein sollte:

const unitSystem = config.unitSystem ?? 'metric';

Auch hier ist das Ergebnis eine uneinheitliche Anzeige.

Wenn du alte Werte der Schnittstelle AppConfig unterstützen musst (vielleicht sind sie als JSON auf der Festplatte oder in einer Datenbank gespeichert), kannst du das neue Feld nicht zur Pflichtangabe machen. Stattdessen kannst du den Typ in zwei Typen aufteilen: einen Typ für nicht-normierte Konfigurationen, die von der Festplatte gelesen werden, und einen anderen mit weniger optionalen Eigenschaften für die Verwendung in deiner App:

interface InputAppConfig {
  darkMode: boolean;
  // ... other settings ...
  /** default is imperial */
  unitSystem?: UnitSystem;
}
interface AppConfig extends InputAppConfig {
  unitSystem: UnitSystem;  // required
}

Wenn es dir seltsam vorkommt, eine optionale Eigenschaft in einem Subtyp in eine erforderliche zu ändern, siehe Punkt 7. Du könntest hier auch Required<InputAppConfig> verwenden.

Du solltest etwas Normalisierungscode hinzufügen:

function normalizeAppConfig(inputConfig: InputAppConfig): AppConfig {
  return {
    ...inputConfig,
    unitSystem: inputConfig.unitSystem ?? 'imperial',
  };
}

Diese Aufteilung löst ein paar Probleme:

  1. Sie ermöglicht es, die Konfiguration weiterzuentwickeln und die Abwärtskompatibilität zu erhalten, ohne die Komplexität der Anwendung zu erhöhen.

  2. Es zentralisiert die Anwendung von Standardwerten.

  3. Das macht es schwer, eine InputAppConfig zu verwenden, wo eine AppConfig erwartet wird.

Diese Art von "under construction"-Typen tauchen häufig bei Netzwerkcodes auf. Siehe UserPosts in Punkt 33 für ein weiteres Beispiel.

Wenn du einem interface mehr optionale Eigenschaften hinzufügst, stößt du auf ein neues Problem: Wenn du N optionale Eigenschaften hast, gibt es 2N mögliche Kombinationen davon. Das ist eine Menge an Möglichkeiten! Wenn du 10 optionale Eigenschaften hast, hast du dann alle 1.024 Kombinationen getestet? Machen alle Kombinationen überhaupt einen Sinn? Es ist wahrscheinlich, dass diese Optionen eine gewisse Struktur haben, vielleicht schließen sie sich gegenseitig aus. Wenn das der Fall ist, sollte dein Staat dies modellieren (siehe Punkt 29). Das ist ein Problem bei Optionen im Allgemeinen, nicht nur bei optionalen Eigenschaften.

Schließlich sind optionale Eigenschaften eine mögliche Quelle von Unsauberkeiten in TypeScript. In Artikel 48 wird dies genauer erläutert.

Wie du gesehen hast, gibt es viele Gründe, optionale Eigenschaften zu vermeiden. Wann solltest du sie also verwenden? Sie sind größtenteils unvermeidbar, wenn du bestehende APIs beschreibst oder APIs weiterentwickelst und dabei die Abwärtskompatibilität wahrst. Bei großen Konfigurationen kann es unerschwinglich sein, alle optionalen Felder mit Standardwerten auszufüllen. Und einige Eigenschaften sind wirklich optional: Nicht jeder hat einen zweiten Vornamen, daher ist eine optionale middleName Eigenschaft für einen Person Typ ein genaues Modell. Aber sei dir der vielen Nachteile optionaler Eigenschaften bewusst und überlege zweimal, bevor du eine optionale Eigenschaft hinzufügst, wenn es eine gute Alternative gibt.

Dinge zum Erinnern

  • Optionale Eigenschaften können verhindern, dass der Typprüfer Fehler findet, und können zu wiederholtem und möglicherweise inkonsistentem Code zum Ausfüllen von Standardwerten führen.

  • Überlege zweimal, bevor du eine optionale Eigenschaft zu einer Schnittstelle hinzufügst. Überlege, ob du sie nicht stattdessen zur Pflicht machen kannst.

  • Ziehe in Erwägung, unterschiedliche Typen für nicht-normalisierte Eingabedaten und normalisierte Daten für die Verwendung in deinem Code zu erstellen.

  • Vermeide eine kombinatorische Explosion von Optionen.

Punkt 38: Vermeide wiederholte Parameter desselben Typs

Was bewirkt dieser Funktionsaufruf?

drawRect(25, 50, 75, 100, 1);

Ohne einen Blick in die Parameterliste der Funktion zu werfen, ist es unmöglich, das zu sagen. Hier sind ein paar Möglichkeiten:

  • Sie zeichnet ein 75 × 100 großes Rechteck, dessen oberer linker Rand bei (25, 50) liegt, mit einer Deckkraft von 1,0.

  • Sie zeichnet ein 50 × 50 großes Rechteck mit den Ecken (25, 50) und (75, 100) und einer Strichstärke von einem Pixel.

Ohne mehr Kontext ist es schwer zu sagen, ob diese Funktion richtig aufgerufen wird. Und da alle Parameter vom gleichen Typ sind, number, kann dir die Typüberprüfung nicht helfen, wenn du die Reihenfolge vertauschst oder eine Breite und Höhe anstelle einer zweiten Koordinate übergibst.

Stell dir vor, das wäre die Funktionsdeklaration:

function drawRect(x: number, y: number, w: number, h: number, opacity: number) {
  // ...
}

Jede Funktion, die aufeinanderfolgende Parameter desselben Typs entgegennimmt, ist fehleranfällig, weil der Typprüfer falsche Aufrufe nicht erkennen kann. Eine Möglichkeit, die Situation zu verbessern, wäre es, unterschiedliche Typen Point und Dimension zu verwenden:

interface Point {
  x: number;
  y: number;
}
interface Dimension {
  width: number;
  height: number;
}
function drawRect(topLeft: Point, size: Dimension, opacity: number) {
  // ...
}

Da die Funktion nun drei Parameter mit drei verschiedenen Typen annimmt, kann der Typprüfer zwischen ihnen unterscheiden. Ein falscher Aufruf, bei dem zwei Punkte übergeben werden, ist ein Fehler:

drawRect({x: 25, y: 50}, {x: 75, y: 100}, 1.0);
//                        ~
// Argument ... is not assignable to parameter of type 'Dimension'.

Eine alternative Lösung wäre, alle Parameter in einem einzigen Objekt zusammenzufassen:

interface DrawRectParams extends Point, Dimension {
  opacity: number;
}
function drawRect(params: DrawRectParams) { /* ... */ }

drawRect({x: 25, y: 50, width: 75, height: 100, opacity: 1.0});

Wenn du eine Funktion so umgestaltest, dass sie ein Objekt anstelle von Positionsparametern annimmt, verbessert das die Klarheit für den menschlichen Leser. Und durch die Zuordnung von Namen zu den einzelnen number hilft es auch dem Typprüfer, falsche Aufrufe zu erkennen.

Im Laufe der Entwicklung deines Codes können Funktionen so verändert werden, dass sie mehr und mehr Parameter benötigen. Auch wenn Positionsparameter anfangs gut funktioniert haben, werden sie irgendwann zu einem Problem. Wie das Sprichwort sagt: "Wenn du eine Funktion mit 10 Parametern hast, hast du wahrscheinlich einige übersehen." Sobald eine Funktion mehr als drei oder vier Parameter benötigt, solltest du sie so umstrukturieren, dass sie weniger Parameter benötigt. (Die max-params Regel von typescript-eslint kann dies erzwingen.)

Wenn die Typen der Parameter identisch sind, solltest du bei Positionsparametern noch vorsichtiger sein. Selbst zwei Parameter können ein Problem darstellen.

Es gibt ein paar Ausnahmen von dieser Regel:

  • Wenn die Argumente kommutativ sind (die Reihenfolge spielt keine Rolle), dann gibt es kein Problem. max(a, b) und isEqual(a, b) sind zum Beispiel eindeutig.

  • Wenn es eine "natürliche" Reihenfolge für die Parameter gibt, ist das Potenzial für Verwirrung geringer. array.slice(start, stop) macht mehr Sinn als stop, start, zum Beispiel. Aber Vorsicht: Die Entwickler sind sich nicht immer einig, was eine "natürliche" Reihenfolge ist. (Ist es Jahr, Monat, Tag? Monat, Tag, Jahr? Tag, Monat, Jahr?)

Wie Scott Meyers in Effective C++ schrieb: "Mache Schnittstellen so, dass sie leicht richtig und schwer falsch zu benutzen sind." Dem kann man nur schwer widersprechen!

Dinge zum Erinnern

  • Vermeide es, Funktionen zu schreiben, die aufeinanderfolgende Parameter mit demselben TypeScript-Typ annehmen.

  • Überarbeite Funktionen, die viele Parameter benötigen, so dass sie weniger Parameter mit unterschiedlichen Typen oder ein einziges Objekt Parameter benötigen.

Punkt 39: Bevorzuge vereinheitlichende Typen gegenüber der Modellierung von Unterschieden

Das Typsystem von TypeScript gibt dir mächtige Werkzeuge, um zwischen Typen zu mappen. Unter Punkt 15 und in Kapitel 6 wird erklärt, wie du viele von ihnen nutzen kannst. Sobald du merkst, dass du eine Transformation mit dem Typsystem modellieren kannst, wirst du vielleicht einen überwältigenden Drang verspüren, dies zu tun. Und das wird sich produktiv anfühlen. So viele Typen! So viel Sicherheit!

Wenn du jedoch die Möglichkeit hast, den Unterschied zwischen zwei Typen zu modellieren, ist es eine bessere Option, den Unterschied zwischen den beiden Typen zu eliminieren. Dann ist keine Maschinerie auf Typenebene erforderlich und der kognitive Aufwand, sich zu merken, mit welcher Version eines Typs du arbeitest, entfällt.

Um das zu verdeutlichen, stell dir vor, du hast eine Schnittstelle, die von einer Datenbanktabelle abgeleitet ist. Datenbanken verwenden in der Regel snake_case für Spaltennamen, so kommen also deine Daten zustande:

interface StudentTable {
  first_name: string;
  last_name: string;
  birth_date: string;
}

In TypeScript-Code werden Eigenschaftsnamen normalerweise in CamelCase geschrieben. Um den Typ Student konsistenter mit dem Rest deines Codes zu machen, kannst du eine alternative Version von Student einführen:

interface Student {
  firstName: string;
  lastName: string;
  birthDate: string;
}

Du kannst eine Funktion schreiben, die zwischen diesen beiden Typen konvertiert. Noch interessanter ist, dass du Templating-Literaltypen verwenden kannst, um diese Funktion zu schreiben. In Artikel 54 erfährst du, wie du das machst, aber das Endergebnis ist, dass du einen Typ aus dem anderen generieren kannst:

type Student = ObjectToCamel<StudentTable>;
//   ^? type Student = {
//        firstName: string;
//        lastName: string;
//        birthDate: string;
//      }

Erstaunlich! Wenn der Nervenkitzel nachlässt, weil du einen überzeugenden Anwendungsfall für die ausgefallene Programmierung auf Typebene gefunden hast, kann es sein, dass du auf viele Fehler stößt, wenn du eine Version des Typs an eine Funktion übergibst, die die andere erwartet:

async function writeStudentToDb(student: Student) {
  await writeRowToDb(db, 'students', student);
  //                                 ~~~~~~~
  // Type 'Student' is not assignable to parameter of type 'StudentTable'.
}

Es ist aus der Fehlermeldung nicht ersichtlich, aber das Problem ist, dass du vergessen hast, deinen Konvertierungscode aufzurufen:

async function writeStudentToDb(student: Student) {
  await writeRowToDb(db, 'students', objectToSnake(student));  // ok
}

Es ist zwar hilfreich, dass TypeScript diesen Fehler erkannt hat, bevor er einen Laufzeitfehler verursacht hat, aber es wäre einfacher, nur eine einzige Version des Typs Student in deinem Code zu haben, damit dieser Fehler nicht auftreten kann.

Es gibt zwei Versionen des Student Typs. Welche solltest du wählen?

  • Um die camelCase-Version zu übernehmen, musst du eine Art Adapter einrichten, der sicherstellt, dass deine Datenbank die camelCase-Version der Spalten zurückgibt. Außerdem musst du dafür sorgen, dass das Tool, mit dem du TypeScript-Typen aus deiner Datenbank generierst, diese Umwandlung kennt. Der Vorteil dieses Ansatzes ist, dass deine Datenbankschnittstellen genauso aussehen wie alle anderen Typen.

  • Um die snake_case-Version zu übernehmen, musst du überhaupt nichts tun. Du musst nur eine oberflächliche Inkonsistenz in der Namenskonvention für eine tiefere Konsistenz in deinen Typen akzeptieren.

Beide Ansätze sind machbar, aber der letztere ist einfacher.

Der allgemeine Grundsatz lautet, dass du vereinheitlichende Typen der Modellierung kleiner Unterschiede zwischen ihnen vorziehen solltest. Allerdings gibt es einige Vorbehalte gegenüber dieser Regel.

Erstens ist die Vereinheitlichung nicht immer eine Option. Vielleicht brauchst du die beiden Typen, wenn die Datenbank und die API nicht unter deiner Kontrolle sind. Wenn das der Fall ist, hilft dir die systematische Modellierung dieser Art von Unterschieden im Typensystem, Fehler in deinem Transformationscode zu finden. Das ist besser, als ad hoc Typen zu erstellen und zu hoffen, dass sie übereinstimmen.

Zweitens: Vereinige keine Typen, die eigentlich nicht dasselbe repräsentieren! "Es wäre zum Beispiel kontraproduktiv, die verschiedenen Typen in einer getaggten Union zu vereinheitlichen, weil sie vermutlich verschiedene Zustände repräsentieren, die du getrennt halten willst.

Dinge zum Erinnern

  • Verschiedene Varianten desselben Typs zu haben, verursacht kognitiven Overhead und erfordert viel Konvertierungscode.

  • Anstatt leichte Variationen eines Typs in deinem Code zu modellieren, solltest du versuchen, die Variationen zu eliminieren, damit du dich auf einen einzigen Typ einigen kannst.

  • Die Vereinheitlichung von Typen kann einige Anpassungen im Laufzeitcode erfordern.

  • Wenn du die Typen nicht unter Kontrolle hast, musst du die Variationen eventuell modellieren.

  • Vereinige keine Typen, die nicht die gleiche Sache darstellen.

Punkt 40: Unpräzise Typen gegenüber ungenauen Typen bevorzugen

Auf wirst du beim Schreiben von Typendeklarationen unweigerlich auf Situationen stoßen, in denen du das Verhalten genauer oder weniger genau modellieren kannst. Präzision bei Typen ist im Allgemeinen einegute 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 33 kennengelernt haben. Eine GeoJSON-Geometrie kann einer von mehreren Typen sein, von denen jeder unterschiedlich geformte Koordinatenfelder hat:

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, muss dein Benutzer Typ-Assertions einführen oder die Typüberprüfung mit as any ganz ausschalten. Vielleicht gibt er auch auf und beginnt, seine eigenen Deklarationen zu schreiben.

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 Mapbox-Bibliothek 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:

  1. Erlaube alles.

  2. Erlaube Strings, Zahlen und Arrays.

  3. Erlaube Zeichenketten, Zahlen und Arrays, die mit bekannten Funktionsnamen beginnen.

  4. Achte darauf, dass jede Funktion die richtige Anzahl von Argumenten erhält.

  5. 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[];

Ein Typensystem wird als "vollständig" bezeichnet, wenn es alle gültigen Programme zulässt. Diese beiden Typen lassen alle gültigen Mapbox-Ausdrücke zu. Es wird keine falsch positiven Fehler geben. Aber bei so einfachen Typen gibt es viele falsch-negative Fehler: ungültige Ausdrücke, die nicht als solche erkannt werden. Mit anderen Worten: Die Typen sind nicht sehr präzise.

Wir wollen sehen, ob wir die Genauigkeit verbessern können, ohne die Eigenschaft der Vollständigkeit zu verlieren. Um Regressionen zu vermeiden, sollten wir eine Testmenge von Ausdrücken einführen, die gültig sind, und von Ausdrücken, die nicht gültig sind.(In Punkt 55 geht es um das Testen von Typen.)

const okExpressions: Expression2[] = [
  10,
  "red",
  ["+", 10, 5],
  ["rgb", 255, 128, 64],
  ["case", [">", 20, 10], "red", "blue"],
];
const invalidExpressions: Expression2[] = [
  true,
// ~~~ Type 'boolean' is not assignable to type 'Expression2'
  ["**", 2, 31],  // Should be an error: no "**" function
  ["rgb", 255, 0, 127, 0],  // Should be an error: too many values
  ["case", [">", 20, 10], "red", "blue", "green"],  // (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 okExpressions: Expression3[] = [
  10,
  "red",
  ["+", 10, 5],
  ["rgb", 255, 128, 64],
  ["case", [">", 20, 10], "red", "blue"],
];
const invalidExpressions: Expression3[] = [
  true,
  // Error: Type 'boolean' is not assignable to type 'Expression3'
  ["**", 2, 31],
  // ~~ Type '"**"' is not assignable to type 'FnName'
  ["rgb", 255, 0, 127, 0],  // Should be an error: too many values
  ["case", [">", 20, 10], "red", "blue", "green"],  // (Too many values)
];

Es gibt einen neuen gefangenen Fehler und keine Rückschritte. Ziemlich gut! Eine Komplikation besteht darin, dass unsere Typendeklarationen nun enger mit unserer Mapbox-Version verbunden sind. Wenn Mapbox eine neue Funktion hinzufügt, müssen die Typendeklarationen diese ebenfalls hinzufügen. Diese Typen sind präziser, aber sie sind auch wartungsintensiver.

Was ist, wenn du sicherstellen willst, dass jede Funktion die richtige Anzahl vonArgumenten erhält? Das wird schwieriger, denn die Typen müssen jetzt rekursiv sein, um alle Funktionsaufrufe zu erreichen. TypeScript erlaubt das, allerdings müssen wir den Type Checker davon überzeugen, dass unsere Rekursion nicht unendlich ist. Es gibt mehrere Möglichkeiten, dies zu tun. Eine davon ist, CaseCall (das ein Array mit gerader Länge sein muss) mit einerinterface und nicht mit type.

Das ist möglich, wenn auch ein bisschen umständlich:

type Expression4 = number | string | CallExpression;

type CallExpression = MathCall | CaseCall | RGBCall;

type MathCall = [
  '+' | '-' | '/' | '*' | '>' | '<',
  Expression4,
  Expression4,
];

interface CaseCall {
  0: 'case';
  [n: number]: Expression4;
  length: 4 | 6 | 8 | 10 | 12 | 14 | 16; // etc.
}

type RGBCall = ['rgb', Expression4, Expression4, Expression4];

Mal sehen, wie wir uns geschlagen haben:

const okExpressions: Expression4[] = [
  10,
  "red",
  ["+", 10, 5],
  ["rgb", 255, 128, 64],
  ["case", [">", 20, 10], "red", "blue"],
];
const invalidExpressions: Expression4[] = [
  true,
// ~~~ Type 'boolean' is not assignable to type 'Expression4'
  ["**", 2, 31],
// ~~~~ Type '"**"' is not assignable to type '"+" | "-" | "/" | ...
  ["rgb", 255, 0, 127, 0],
  //                   ~ Type 'number' is not assignable to type 'undefined'.
  ["case", [">", 20, 10], "red", "blue", "green"],
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  // Types of property 'length' are incompatible.
  //    Type '5' is not assignable to type '4 | 6 | 8 | 10 | 12 | 14 | 16'.
];

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 etwas verwirrend, besonders die über Type '5'.

Ist dies eine Verbesserung gegenüber den vorherigen, weniger präzisen Typen? Die Tatsache, dass du bei mehr falschen Verwendungen Fehler bekommst, ist definitiv ein Gewinn, aber verwirrende Fehlermeldungen machen es schwieriger, mit diesem Typ zu arbeiten. Wie in Punkt 6 erläutert, gehören Sprachdienste genauso zum TypeScript-Erlebnis wie die Typüberprüfung. Es ist also eine gute Idee, sich die Fehlermeldungen deiner Typdeklarationen anzusehen und die Autovervollständigung in Situationen auszuprobieren, in denen sie funktionieren sollte. Wenn deine neuen Typendeklarationen zwar präziser sind, aber die Autovervollständigung stören, macht die TypeScript-Entwicklung weniger Spaß.

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 moreOkExpressions: Expression4[] = [
  ['-', 12],
  // ~~~~~~ Type '["-", number]' is not assignable to type 'MathCall'.
  //          Source has 2 element(s) but target requires 3.
  ['+', 1, 2, 3],
  //          ~ Type 'number' is not assignable to type 'undefined'.
  ['*', 2, 3, 4],
  //          ~ Type 'number' is not assignable to type 'undefined'.
];

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. Je lebensechter eine Zeichnung wird, desto realistischer nehmen wir sie wahr. Aber nur bis zu einem gewissen Punkt. Wenn sie zu realistisch wird, neigen wir dazu, uns auf die wenigen verbleibenden Ungenauigkeiten zu konzentrieren.

Genauso ist es fast immer hilfreich, sehr ungenaue Typen wie any zu verfeinern. Du und deine Kollegen werden dies als Verbesserung der Tippsicherheit und Produktivität wahrnehmen. Aber je präziser deine Typen werden, desto höher ist die Erwartung, dass sie auch genau sein werden. Du vertraust darauf, dass die Maschinen die meisten Fehler erkennen, und so fallen die Ungenauigkeiten noch deutlicher auf. Wenn du Stunden damit verbringst, einen Typfehler aufzuspüren, nur um dann festzustellen, dass die Typen ungenau sind, untergräbt das das Vertrauen in deine Typendeklarationen und vielleicht auch in TypeScript selbst. Deine Produktivität wird dadurch sicher nicht gesteigert!

Dinge zum Erinnern

  • Vermeide das unheimliche Tal der Typsicherheit: Komplexe, aber ungenaue Typen sind oft schlechter als einfachere, ungenauere Typen. Wenn du einen Typ nicht genau modellieren kannst, dann modelliere ihn nicht ungenau! Erkenne die Lücken mit any oder unknown an.

  • 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.

  • Wenn deine Typen immer komplexer werden, sollte sich auch deine Testsuite für sie erweitern.

Punkt 41: Benenne Typen in der Sprachedeines Problembereichs

In der Informatik gibt es nur zwei schwierige Probleme: die Ungültigmachung von Caches und dieBenennung 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 gefassten string Typs(Punkt 35), sondern auch weil unklar ist, was mit "Lebensraum" gemeint ist.

  • Der Variablenname ist leopard, aber der Wert der Eigenschaft name 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 ist status geworden, eine ConservationStatus Art, die ein Standardklassifizierungssystem der IUCN verwendet.

  • habitat ist climates 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 das Unternehmen 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 möchtest oder herausfinden willst, was ein Schutzstatus genau bedeutet, findest du im Internet unzählige Ressourcen, die dir dabei 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.

Die gleichen Überlegungen gelten auch für andere Bezeichnungen, wie z. B. Funktionsparameternamen, Tupel-Bezeichnungen und Index-Typ-Bezeichnungen.

Hier sind noch ein paar andere Regeln, die du bei der Benennung 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 verwendest, weil dir kein aussagekräftigerer Name einfällt, wirst du irgendwann Probleme bekommen: Vielleicht gibt es in deinem Projekt mehrere verschiedene Typen, die "Entity" heißen, und kannst du dich erinnern, was ein Item und was ein Entity ist?

  • Benenne Dinge nach dem, was sie sind, und nicht nach dem, was sie enthalten oder wie sie berechnet werden. Directory ist aussagekräftiger als INodeList. 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.

Dinge zum Erinnern

  • Verwende nach Möglichkeit Namen aus dem Bereich deines Problems, um die Lesbarkeit und das Abstraktionsniveau deines Codes zu erhöhen. Achte darauf, dass du die Begriffe aus dem Fachgebiet richtig verwendest.

  • Vermeide es, verschiedene Namen für ein und dieselbe Sache zu verwenden: Mach die Unterschiede in den Namen sinnvoll.

  • Vermeide vage Namen wie "Info" oder "Entität". Benenne Typen nach dem, was sie sind, und nicht nach ihrer Form.

Punkt 42: Vermeide Typen, die auf anekdotischen Daten basieren

Die anderen Artikel in diesem Kapitel haben die vielen Vorteile eines guten Typendesigns erörtert und gezeigt, was ohne sie schiefgehen kann. Ein gut entworfener Typ macht die Verwendung von TypeScript zu einem Vergnügen, während ein schlecht entworfener 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 machen müsstest?

Zumindest einige deiner Typen werden wahrscheinlich von außerhalb deines Programms kommen: Spezifikationen, Dateiformate, APIs oder Datenbankschemata. Es ist verlockend, selbst Deklarationen für diese Typen zu schreiben, die auf den Daten basieren, die du gesehen hast, z. B. die Zeilen in deiner Testdatenbank oder die Antworten, die du von einem bestimmten API-Endpunkt erhalten hast.

Widerstehe diesem Drang! Es ist viel besser, Typen aus einer anderen Quelle zu importieren oder sie aus einer Spezifikation zu erstellen. Wenn du Typen selbst schreibst und dabei auf Erfahrungswerte zurückgreifst, berücksichtigst du nur die Beispiele, die du gesehen hast. Du könntest wichtige Kanten übersehen, die dein Programm kaputt machen könnten. Wenn du offiziellere Typen verwendest, sorgt TypeScript dafür, dass das nicht passiert.

In Punkt 30 haben wir eine Funktion verwendet, die die Bounding Box eines GeoJSON-Features berechnet. So könnte eine Definition aussehen:

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;
}

Wie würdest du den Typ GeoJSONFeature definieren? Du könntest dir einige GeoJSON-Features in deinem Repo ansehen und eine interface skizzieren:

interface GeoJSONFeature {
  type: 'Feature';
  geometry: GeoJSONGeometry | null;
  properties: unknown;
}
interface GeoJSONGeometry {
  type: 'Point' | 'LineString' | 'Polygon' | 'MultiPolygon';
  coordinates: number[] | number[][] | number[][][] | number[][][][];
}

Mit dieser Definition besteht die Funktion die Typprüfung. Aber ist sie wirklich korrekt? Diese Prüfung ist nur so gut wie unsere selbst erstellten Typendeklarationen.

Ein besserer Ansatz wäre es, 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:2

$ npm install --save-dev @types/geojson
+ @types/geojson@7946.0.14

Bei diesen Deklarationen zeigt TypeScript 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 dieser 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 Geometry​Col⁠lec⁠tion ist, wird ein Fehler ausgegeben, weil die Eigenschaft 0 vonundefined nicht gelesen werden kann. Das ist ein echter Fehler! Wir haben ihn gefunden, indem wir Typen aus derCommunity gesammelt haben.

Eine Möglichkeit, den Fehler zu beheben, besteht darin, GeometryCollections ausdrücklich zu verbieten:

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, GeometryCollections zu unterstützen! Das kannst du tun, indem du eine weitere Hilfsfunktion herausziehst:

const geometryHelper = (g: Geometry) => {
  if (g.type === 'GeometryCollection') {
    g.geometries.forEach(geometryHelper);
  } else {
    helper(g.coordinates);  // OK
  }
}

const {geometry} = f;
if (geometry) {
  geometryHelper(geometry);
}

Unsere handgeschriebenen GeoJSON-Typen basierten nur auf unseren eigenen Erfahrungen mit dem Format, die keine GeometryCollections enthielten. Das führte zu einem falschen Sicherheitsgefühl bezüglich der Korrektheit unseres Codes. Die Verwendung von Community-Typen, die auf einer Spezifikation basieren, gibt dir die Gewissheit, dass dein Code mit allen Werten funktioniert, nicht nur mit denen, die du zufällig gesehen hast.

Ähnliche Überlegungen gelten für API-Aufrufe. Wenn es einen offiziellen TypeScript-Client für die API gibt, mit der du arbeitest, verwende ihn! Aber selbst wenn nicht, kannst du vielleicht TypeScript-Typen aus einer offiziellen Quelle generieren.

Wenn du zum Beispiel eine GraphQL-API verwendest, enthält sie ein Schema, das alle Abfragen und Mutationen sowie alle Typen beschreibt. Es gibt viele Tools, mit denen du TypeScript-Typen zu GraphQL-Abfragen hinzufügen kannst. Besuche deine Lieblingssuchmaschine und du wirst schnell auf dem Weg zur Typsicherheit sein.

Viele REST-APIs veröffentlichen ein OpenAPI-Schema. Dabei handelt es sich um eine Datei, in der alle Endpunkte, HTTP-Verben (GET, POST usw.) und Typen mithilfe des JSON-Schemas beschrieben werden.

Nehmen wir an, wir verwenden eine API, mit der wir Kommentare in einem Blog veröffentlichen können. So könnte ein OpenAPI-Schema aussehen:

// schema.json
{
  "openapi": "3.0.3",
  "info": { "version": "1.0.0", "title": "Sample API" },
  "paths": {
    "/comment": {
      "post": {
        "requestBody": { "content": { "application/json": {
          "schema": { "$ref": "#/components/schemas/Comment" }
        }}}
      },
      "responses": {
        "200": { /* ... */ }
      }
    }
  },
  "components": {
    "schemas": {
      "CreateCommentRequest": {
        "properties": {
            "body": { "type": "string" },
            "postId": { "type": "string" },
            "title": { "type": "string" }
        },
        "type": "object",
        "required": ["postId", "title", "body"]
      }
    }
  }
}

Der Abschnitt paths definiert die Endpunkte und verknüpft sie mit Typen, die sich im Abschnitt components/schemas befinden. Alle Informationen, die wir brauchen, um Typen zu erzeugen, stehen hier. Es gibt viele Möglichkeiten, um Typen aus einem OpenAPI-Schema zu erzeugen. Eine ist, die Schemas zu extrahieren und sie durch json-schema-to-typescript laufen zu lassen:

$ jq .components.schemas.CreateCommentRequest schema.json > comment.json
$ npx json-schema-to-typescript comment.json > comment.ts
$ cat comment.ts
// ....
export interface CreateCommentRequest {
  body: string;
  postId: string;
  title: string;
}

Das Ergebnis sind schöne, saubere interfaces, die dir helfen, mit dieser API auf eine typsichere Weise zu interagieren. TypeScript kennzeichnet Typfehler in deinen Anfragekörpern und die Antworttypen fließen durch deinen Code. Das Wichtigste ist, dass du die Typen nicht selbst geschrieben hast. Sie werden vielmehr aus einer verlässlichen Quelle der Wahrheit generiert. Wenn ein Feld optional ist oder null sein kann, weiß TypeScript das und zwingt dich, diese Möglichkeit zu nutzen.

Ein nächster Schritt wäre hier, eine Laufzeitvalidierung hinzuzufügen und die Typen direkt mit den Endpunkten zu verbinden, mit denen sie verbunden sind. Es gibt viele Tools, die dir dabei helfen können, und Punkt 74 wird auf dieses Beispiel zurückkommen.

Wenn du Typen generierst, musst du sicherstellen, dass sie mit dem API-Schema übereinstimmen. In Punkt 58 werden Strategien für diesen Fall beschrieben.

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. Sei dir aber bewusst, dass deine Typen möglicherweise nicht mit der Realität übereinstimmen: Es kann Kanten geben, die du übersehen hast. (Eine Ausnahme wäre, wenn dein Datensatz endlich ist, z. B. ein Verzeichnis mit 1.000 JSON-Dateien. Dann weißt du, dass du nichts übersehen hast!)

Auch wenn du dir dessen nicht bewusst bist, profitierst du bereits von der Codegenerierung. Die Typdeklarationen von TypeScript für die Browser-DOM-API, die in Punkt 75 untersucht werden, werden aus den API-Beschreibungen auf MDN generiert. Das stellt sicher, dass sie ein kompliziertes System korrekt modellieren und hilft TypeScript, Fehler und Missverständnisse in deinem eigenen Code zu erkennen.

Dinge zum Erinnern

  • Vermeide es, Typen auf der Grundlage von Daten, die du gesehen hast, von Hand zu schreiben. Es ist leicht, ein Schema falsch zu verstehen oder die Nullbarkeit zu verwechseln.

  • Bevorzuge Typen, die von offiziellen Clients oder der Community stammen. Wenn es diese nicht gibt, generiere TypeScript-Typen aus Schemata.

1 GeoJSON ist auch als RFC 7946 bekannt. Die sehr lesenswerte Spezifikation findest du unter http://geojson.org.

2 Die ungewöhnlich große Hauptversionsnummer stimmt mit der RFC-Nummer überein. Das war damals ganz nett, hat sich aber in der Praxis als lästig erwiesen.

Get Effektives TypeScript, 2. Auflage 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.