Kapitel 4. Typ Design

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Wenn du mir deine Flussdiagramme zeigst und deine Tabellen versteckst, werde ich weiterhin verwirrt sein. Zeig mir deine Tabellen, und ich brauche deine Flussdiagramme normalerweise nicht; sie werden offensichtlich sein.

Fred Brooks, Der Monat des Mythos Mensch

Die Sprache in Fred Brooks' Zitat ist veraltet, aber die Aussage ist nach wie vor richtig: Code ist schwer zu verstehen, wenn man die Daten oder Datentypen, mit denen er arbeitet, nicht sehen kann. Das ist einer der großen Vorteile eines Typensystems: Indem du Typen ausschreibst, machst du sie für die Leser deines Codes sichtbar. Und das macht deinen Code verständlich.

In anderen Kapiteln geht es um die Grundlagen von TypeScript-Typen: wie man sie verwendet, wie man sie herleitet und wie man Deklarationen mit ihnen schreibt. In diesem Kapitel geht es um das Design der Typen selbst. Die Beispiele in diesem Kapitel sind alle mit Blick auf TypeScript geschrieben, aber die meisten Ideen sind auch allgemeiner anwendbar.

Wenn du deine Typen gut schreibst, dann werden mit etwas Glück auch deine Flussdiagramme eindeutig sein.

Punkt 28: Typen bevorzugen, die immer gültige Zustände darstellen

Wenn du deine Typen gut entwirfst, sollte dein Code einfach zu schreiben sein. Wenn du deine Typen jedoch schlecht entwirfst, kann dich keine noch so clevere Dokumentation retten. Dein Code wird verwirrend und fehleranfällig sein.

Ein Schlüssel zu effektivem Typendesign ist die Erstellung von Typen, die nur einen gültigen Zustand darstellen können. In diesem Artikel gehen wir ein paar Beispiele durch, wie das schiefgehen kann, und zeigen dir, wie du sie beheben kannst.

Angenommen, du baust eine Webanwendung, mit der du eine Seite auswählen, den Inhalt dieser Seite laden und dann anzeigen kannst. Du könntest den Status wie folgt schreiben:

interface State {
  pageText: string;
  isLoading: boolean;
  error?: string;
}

Wenn du deinen Code zum Rendern der Seite schreibst, musst du alle diese Felder berücksichtigen:

function renderPage(state: State) {
  if (state.error) {
    return `Error! Unable to load ${currentPage}: ${state.error}`;
  } else if (state.isLoading) {
    return `Loading ${currentPage}...`;
  }
  return `<h1>${currentPage}</h1>\n${state.pageText}`;
}

Aber ist das richtig? Was ist, wenn isLoading und error beide eingestellt sind? Was würde das bedeuten? Ist es besser, die Ladenachricht oder die Fehlermeldung anzuzeigen? Das ist schwer zu sagen! Es sind nicht genug Informationen verfügbar.

Oder was, wenn du eine changePage Funktion schreibst? Hier ist ein Versuch:

async function changePage(state: State, newPage: string) {
  state.isLoading = true;
  try {
    const response = await fetch(getUrlForPage(newPage));
    if (!response.ok) {
      throw new Error(`Unable to load ${newPage}: ${response.statusText}`);
    }
    const text = await response.text();
    state.isLoading = false;
    state.pageText = text;
  } catch (e) {
    state.error = '' + e;
  }
}

Es gibt viele Probleme damit! Hier sind ein paar:

  • Wir haben vergessen, state.isLoading im Fehlerfall auf false zu setzen.

  • Wir haben state.error nicht gelöscht. Wenn also die vorherige Anfrage fehlgeschlagen ist, siehst du weiterhin diese Fehlermeldung anstelle einer Ladenachricht.

  • Wenn der Nutzer erneut die Seite wechselt, während die Seite geladen wird, wer weiß, was dann passiert. Vielleicht sieht er eine neue Seite und dann eine Fehlermeldung, oder die erste Seite und nicht die zweite, je nachdem, in welcher Reihenfolge die Antworten zurückkommen.

Das Problem ist, dass der Status sowohl zu wenig Informationen enthält (welche Anfrage ist fehlgeschlagen? welche wird gerade geladen?) als auch zu viele: Der Typ State erlaubt es, sowohl isLoading als auch error zu setzen, obwohl dies einen ungültigen Status darstellt. Das macht sowohl render() und changePage() unmöglich, gut zu implementieren.

Hier ist ein besserer Weg, um den Zustand der Anwendung darzustellen:

interface RequestPending {
  state: 'pending';
}
interface RequestError {
  state: 'error';
  error: string;
}
interface RequestSuccess {
  state: 'ok';
  pageText: string;
}
type RequestState = RequestPending | RequestError | RequestSuccess;

interface State {
  currentPage: string;
  requests: {[page: string]: RequestState};
}

Dabei wird eine "tagged union" (auch bekannt als "discriminated union") verwendet, um die verschiedenen Zustände, die eine Netzwerkanfrage haben kann, explizit zu modellieren. Diese Version des Zustands ist drei- bis viermal so lang, hat aber den enormen Vorteil, dass sie keine ungültigen Zustände zulässt. Die aktuelle Seite wird explizit modelliert, ebenso wie der Zustand jeder Anfrage, die du stellst. Daher sind die Funktionen renderPage und changePage einfach zu implementieren:

function renderPage(state: State) {
  const {currentPage} = state;
  const requestState = state.requests[currentPage];
  switch (requestState.state) {
    case 'pending':
      return `Loading ${currentPage}...`;
    case 'error':
      return `Error! Unable to load ${currentPage}: ${requestState.error}`;
    case 'ok':
      return `<h1>${currentPage}</h1>\n${requestState.pageText}`;
  }
}

async function changePage(state: State, newPage: string) {
  state.requests[newPage] = {state: 'pending'};
  state.currentPage = newPage;
  try {
    const response = await fetch(getUrlForPage(newPage));
    if (!response.ok) {
      throw new Error(`Unable to load ${newPage}: ${response.statusText}`);
    }
    const pageText = await response.text();
    state.requests[newPage] = {state: 'ok', pageText};
  } catch (e) {
    state.requests[newPage] = {state: 'error', error: '' + e};
  }
}

Die Zweideutigkeit der ersten Implementierung ist vollständig beseitigt: Es ist klar, was die aktuelle Seite ist, und jede Anfrage befindet sich in genau einem Zustand. Wenn der Nutzer die Seite ändert, nachdem eine Anfrage gestellt wurde, ist das auch kein Problem. Die alte Anfrage wird immer noch abgeschlossen, aber sie hat keine Auswirkungen auf die Benutzeroberfläche.

Ein einfacheres, aber noch schlimmeres Beispiel ist das Schicksal von Air France Flug 447, einem Airbus 330, der am 1. Juni 2009 über dem Atlantik verschwand. Der Airbus war ein Fly-by-Wire-Flugzeug, d.h. die Steuereingaben der Piloten gingen durch ein Computersystem, bevor sie sich auf die physischen Steuerflächen des Flugzeugs auswirkten. Nach dem Absturz wurden viele Fragen darüber aufgeworfen, ob es klug ist, sich bei Entscheidungen über Leben und Tod auf Computer zu verlassen. Als zwei Jahre später die Flugschreiber geborgen wurden, enthüllten sie viele Faktoren, die zu dem Absturz führten. Ein wichtiger Faktor war jedoch die schlechte Konstruktion des Flugzeugs.

Das Cockpit des Airbus 330 hatte getrennte Bedienelemente für den Piloten und den Kopiloten. Mit den Seitensteuerknüppeln wurde der Anstellwinkel gesteuert. Wenn du ihn nach hinten ziehst, steigt das Flugzeug, wenn du ihn nach vorne drückst, geht es in den Sturzflug. Der Airbus 330 verwendete ein System namens "Dual Input"-Modus, bei dem sich die beiden seitlichen Steuerknüppel unabhängig voneinander bewegen konnten. Hier siehst du, wie du den Zustand in TypeScript modellieren kannst:

interface CockpitControls {
  /** Angle of the left side stick in degrees, 0 = neutral, + = forward */
  leftSideStick: number;
  /** Angle of the right side stick in degrees, 0 = neutral, + = forward */
  rightSideStick: number;
}

Angenommen, du bekommst diese Datenstruktur und sollst eine getStickSetting Funktion schreiben, die die aktuelle Stick-Einstellung berechnet. Wie würdest du das machen?

Eine Möglichkeit wäre, davon auszugehen, dass der Pilot (der auf der linken Seite sitzt) die Kontrolle hat:

function getStickSetting(controls: CockpitControls) {
  return controls.leftSideStick;
}

Aber was ist, wenn der Kopilot die Kontrolle übernommen hat? Vielleicht solltest du den Steuerknüppel benutzen, der vom Nullpunkt weg ist:

function getStickSetting(controls: CockpitControls) {
  const {leftSideStick, rightSideStick} = controls;
  if (leftSideStick === 0) {
    return rightSideStick;
  }
  return leftSideStick;
}

Aber es gibt ein Problem bei dieser Implementierung: Wir können nur dann sicher sein, dass die linke Einstellung zurückgegeben wird, wenn die rechte neutral ist. Du solltest das also überprüfen:

function getStickSetting(controls: CockpitControls) {
  const {leftSideStick, rightSideStick} = controls;
  if (leftSideStick === 0) {
    return rightSideStick;
  } else if (rightSideStick === 0) {
    return leftSideStick;
  }
  // ???
}

Was tust du, wenn beide Werte ungleich Null sind? Hoffentlich sind sie ungefähr gleich, dann kannst du einfach den Durchschnitt bilden:

function getStickSetting(controls: CockpitControls) {
  const {leftSideStick, rightSideStick} = controls;
  if (leftSideStick === 0) {
    return rightSideStick;
  } else if (rightSideStick === 0) {
    return leftSideStick;
  }
  if (Math.abs(leftSideStick - rightSideStick) < 5) {
    return (leftSideStick + rightSideStick) / 2;
  }
  // ???
}

Aber was, wenn sie es nicht sind? Kannst du einen Fehler auslösen? Nicht wirklich: Die Querruder müssen in einem bestimmten Winkel eingestellt sein!

An Bord von Air France 447 zog der Kopilot seinen Steuerknüppel leise zurück, als das Flugzeug in einen Sturm geriet. Es gewann an Höhe, verlor aber schließlich an Geschwindigkeit und geriet in einen Strömungsabriss, bei dem sich das Flugzeug zu langsam bewegt, um effektiv Auftrieb zu erzeugen. Es begann zu sinken.

Um einem Strömungsabriss zu entgehen, sind Piloten darauf trainiert, die Steuerknüppel nach vorne zu drücken, damit das Flugzeug in den Sturzflug übergeht und wieder an Geschwindigkeit gewinnt. Genau das hat der Pilot getan. Aber der Kopilot zog immer noch stillschweigend an seinem Seitensteuer. Und die Funktion des Airbus sah so aus:

function getStickSetting(controls: CockpitControls) {
  return (controls.leftSideStick + controls.rightSideStick) / 2;
}

Obwohl der Pilot den Steuerknüppel voll nach vorne drückte, ging er im Durchschnitt ins Leere. Er hatte keine Ahnung, warum das Flugzeug nicht tauchte. Als der Copilot herausfand, was er getan hatte, hatte das Flugzeug bereits zu viel Höhe verloren, um sich zu erholen, und stürzte ins Meer, wobei alle 228 Menschen an Bord ums Leben kamen.

Der Punkt ist, dass es keinen guten Weg gibt, getStickSetting mit dieser Eingabe zu implementieren! Die Funktion wurde so eingerichtet, dass sie fehlschlägt. In den meisten Flugzeugen sind die beiden Steuerknüppel mechanisch miteinander verbunden. Wenn der Copilot den Steuerknüppel zurückzieht, zieht auch der Pilot den Steuerknüppel zurück. Der Zustand dieser Steuerungen lässt sich einfach ausdrücken:

interface CockpitControls {
  /** Angle of the stick in degrees, 0 = neutral, + = forward */
  stickAngle: number;
}

Und nun, wie in dem Zitat von Fred Brooks am Anfang des Kapitels, sind unsere Flussdiagramme offensichtlich. Du brauchst die Funktion getStickSetting überhaupt nicht.

Wenn du deine Typen entwirfst, solltest du darauf achten, welche Werte du einbeziehst und welche du ausschließt. Wenn du nur Werte zulässt, die gültige Zustände repräsentieren, lässt sich dein Code leichter schreiben und TypeScript hat es leichter, ihn zu überprüfen. Dies ist ein sehr allgemeines Prinzip, das in den anderen Abschnitten dieses Kapitels noch genauer erläutert wird.

Dinge zum Erinnern

  • 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, werden sie dir am Ende Zeit und Mühe ersparen!

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

Diese Idee ist als Robustheitsprinzip oder Postel's Law bekannt, nach Jon Postel, der es im Zusammenhang mit TCP geschrieben hat:

TCP-Implementierungen sollten einem allgemeinen Grundsatz der Robustheit folgen: Sei konservativ in dem, was du tust, sei liberal in dem, was du von anderen akzeptierst.

Eine ähnliche Regel gilt für die Verträge für Funktionen. Es ist in Ordnung, wenn deine Funktionen breit gefächert sind, was sie als Eingaben akzeptieren, aber sie sollten im Allgemeinen spezifischer sein, was sie als Ausgaben produzieren.

Eine 3D-Mapping-API könnte zum Beispiel eine Möglichkeit bieten, die Kamera zu positionieren und ein Ansichtsfenster für eine Bounding Box zu berechnen:

declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;

Es ist praktisch, dass das Ergebnis von viewportForBounds direkt an setCamera übergeben werden kann, um die Kamera zu positionieren.

Schauen wir uns die Definitionen dieser Typen an:

interface CameraOptions {
  center?: LngLat;
  zoom?: number;
  bearing?: number;
  pitch?: number;
}
type LngLat =
  { lng: number; lat: number; } |
  { lon: number; lat: number; } |
  [number, number];

Die Felder in CameraOptions sind alle optional, denn es kann sein, dass du nur den Mittelpunkt oder den Zoom einstellen willst, ohne die Peilung oder den Abstand zu ändern. Durch den Typ LngLat ist setCamera außerdem sehr flexibel bei der Annahme von Objekten: Du kannst ein {lng, lat} Objekt, ein {lon, lat} Objekt oder ein [lng, lat] Paar übergeben, wenn du dir sicher bist, dass du die richtige Reihenfolge erwischt hast. Diese Anpassungen machen den Aufruf der Funktion einfach.

Die Funktion viewportForBounds nimmt einen anderen "liberalen" Typ auf:

type LngLatBounds =
  {northeast: LngLat, southwest: LngLat} |
  [LngLat, LngLat] |
  [number, number, number, number];

Du kannst die Grenzen entweder mit benannten Ecken, einem Paar von Breiten- und Längengraden oder einem Vierertupel angeben, wenn du sicher bist, dass du die richtige Reihenfolge erwischt hast. Da LngLat bereits drei Formen zulässt, gibt es nicht weniger als 19 mögliche Formen für LngLatBounds. Wirklich liberal!

Jetzt lass uns eine Funktion schreiben, die den Viewport an ein GeoJSON Feature anpasst und den neuen Viewport in der URL speichert (für eine Definition von calculateBoundingBox siehe Punkt 31):

function focusOnFeature(f: Feature) {
  const bounds = calculateBoundingBox(f);
  const camera = viewportForBounds(bounds);
  setCamera(camera);
  const {center: {lat, lng}, zoom} = camera;
               // ~~~      Property 'lat' does not exist on type ...
               //      ~~~ Property 'lng' does not exist on type ...
  zoom;  // Type is number | undefined
  window.location.search = `?v=@${lat},${lng}z${zoom}`;
}

Huch! Es gibt nur die Eigenschaft zoom, aber ihr Typ wird als number|undefined abgeleitet, was ebenfalls problematisch ist. Das Problem ist, dass die Typdeklaration für viewportForBounds darauf hinweist, dass sie nicht nur in dem, was sie akzeptiert, sondern auch in dem, was sie produziert, liberal ist. Die einzige typsichere Möglichkeit, das Ergebnis von camera zu verwenden, besteht darin, für jede Komponente des Union-Typs einen Code-Zweig einzuführen(Punkt 22).

Der Rückgabetyp mit vielen optionalen Eigenschaften und Union-Typen macht viewportForBounds schwierig zu verwenden. Der breite Parametertyp ist praktisch, aber der breite Rückgabetyp ist es nicht. Eine bequemere API wäre streng in dem, was sie produziert.

Eine Möglichkeit, dies zu tun, besteht darin, ein kanonisches Format für Koordinaten zu unterscheiden. In Anlehnung an die Konvention von JavaScript zur Unterscheidung von "Array" und "Array-ähnlich"(Punkt 16) kannst du zwischen LngLat und LngLatLike unterscheiden. Du kannst auch zwischen einem vollständig definierten Typ Camera und der von setCamera akzeptierten Teilversion unterscheiden:

interface LngLat { lng: number; lat: number; };
type LngLatLike = LngLat | { lon: number; lat: number; } | [number, number];

interface Camera {
  center: LngLat;
  zoom: number;
  bearing: number;
  pitch: number;
}
interface CameraOptions extends Omit<Partial<Camera>, 'center'> {
  center?: LngLatLike;
}
type LngLatBounds =
  {northeast: LngLatLike, southwest: LngLatLike} |
  [LngLatLike, LngLatLike] |
  [number, number, number, number];

declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): Camera;

Der lose CameraOptions Typ passt sich dem strengeren Camera Typ an(Punkt 14).

Die Verwendung von Partial<Camera> als Parametertyp in setCamera würde hier nicht funktionieren, da du LngLatLike Objekte für die Eigenschaft center zulassen willst. Außerdem kannst du nicht "CameraOptions extends Partial<Camera>" schreiben, da LngLatLike eine Obermenge von LngLat ist, keine Untermenge(Punkt 7). Wenn dir das zu kompliziert erscheint, kannst du den Typ auch explizit ausschreiben, was allerdings einige Wiederholungen mit sich bringt:

interface CameraOptions {
  center?: LngLatLike;
  zoom?: number;
  bearing?: number;
  pitch?: number;
}

In jedem Fall besteht die Funktion focusOnFeature mit diesen neuen Typendeklarationen die Typprüfung:

function focusOnFeature(f: Feature) {
  const bounds = calculateBoundingBox(f);
  const camera = viewportForBounds(bounds);
  setCamera(camera);
  const {center: {lat, lng}, zoom} = camera;  // OK
  zoom;  // Type is number
  window.location.search = `?v=@${lat},${lng}z${zoom}`;
}

Diesmal ist der Typ von zoom number , statt number|undefined. Die Funktion viewportForBounds ist jetzt viel einfacher zu benutzen. Wenn es noch andere Funktionen gäbe, die Schranken erzeugen, müsstest du auch eine kanonische Form und eine Unterscheidung zwischen LngLatBounds und LngLatBoundsLike einführen.

Ist es ein gutes Design, 19 mögliche Formen von Bounding Boxen zuzulassen? Vielleicht nicht. Aber wenn du Typendeklarationen für eine Bibliothek schreibst, die das tut, musst du ihr Verhalten modellieren. Aber bitte nicht mit 19 Rückgabetypen!

Dinge zum Erinnern

  • Eingabetypen sind in der Regel breiter gefasst als Ausgabetypen. Optionale Eigenschaften und Vereinigungsarten sind bei Parametertypen häufiger anzutreffen als bei Rückgabetypen.

  • Um Typen zwischen Parametern und Rückgabetypen wiederzuverwenden, führe eine kanonische Form (für Rückgabetypen) und eine lockerere Form (für Parameter) ein.

Punkt 30: Keine Wiederholung von Typinformationen in der Dokumentation

Was ist falsch an diesem Code?

/**
 * Returns a string with the foreground color.
 * Takes zero or one arguments. With no arguments, returns the
 * standard foreground color. With one argument, returns the foreground color
 * for a particular page.
 */
function getForegroundColor(page?: string) {
  return page === 'login' ? {r: 127, g: 127, b: 127} : {r: 0, g: 0, b: 0};
}

Der Code und der Kommentar stimmen nicht überein! Ohne mehr Kontext ist es schwer zu sagen, was richtig ist, aber irgendetwas stimmt eindeutig nicht. Wie ein Professor von mir zu sagen pflegte: "Wenn dein Code und deine Kommentare nicht übereinstimmen, sind sie beide falsch!"

Gehen wir davon aus, dass der Code das gewünschte Verhalten darstellt. Es gibt ein paar Probleme mit diesem Kommentar:

  • Es heißt, dass die Funktion die Farbe als string zurückgibt, obwohl sie eigentlich ein {r, g, b} Objekt zurückgibt.

  • Sie erklärt, dass die Funktion null oder ein Argument benötigt, was bereits aus der Typsignatur ersichtlich ist.

  • Er ist unnötig wortreich: Der Kommentar ist länger als die Funktionsdeklaration und die Implementierung!

Das TypeScript-System ist so konzipiert, dass es kompakt, beschreibend und lesbar ist. Seine Entwickler sind Sprachexperten mit jahrzehntelanger Erfahrung. Es ist mit Sicherheit ein besserer Weg, die Typen der Ein- und Ausgänge deiner Funktion auszudrücken als deine Prosa!

Und da deine Typ-Annotationen vom TypeScript-Compiler überprüft werden, können sie nie mit der Implementierung nicht übereinstimmen. Vielleicht hat getForegroundColor früher einen String zurückgegeben, wurde aber später geändert, um ein Objekt zurückzugeben. Derjenige, der die Änderung vorgenommen hat, hat vielleicht vergessen, den langen Kommentar zu aktualisieren.

Nichts bleibt synchron, es sei denn, es wird dazu gezwungen. Mit Typ-Annotationen ist TypeScripts Typprüfung dieser Zwang! Wenn du Typinformationen in Annotationen und nicht in die Dokumentation schreibst, kannst du dich darauf verlassen, dass sie auch bei der Weiterentwicklung des Codes korrekt bleiben.

Ein besserer Kommentar könnte wie folgt aussehen:

/** Get the foreground color for the application or a specific page. */
function getForegroundColor(page?: string): Color {
  // ...
}

Wenn du einen bestimmten Parameter beschreiben willst, verwende eine @param JSDoc-Anmerkung. Mehr dazu findest du unter Punkt 48.

Kommentare über fehlende Mutationen sind ebenfalls verdächtig. Sag nicht einfach, dass du einen Parameter nicht veränderst:

/** Does not modify nums */
function sort(nums: number[]) { /* ... */ }

Deklariere sie stattdessen als readonly (Punkt 17) und lass TypeScript den Vertrag erzwingen:

function sort(nums: readonly number[]) { /* ... */ }

Was für Kommentare gilt, gilt auch für Variablennamen. Vermeide es, Typen in ihnen zu verwenden: Anstatt eine Variable ageNum zu nennen, nenne sie age und stelle sicher, dass sie wirklich eine number ist.

Eine Ausnahme hiervon sind Zahlen mit Einheiten. Wenn nicht klar ist, um welche Einheiten es sich handelt, solltest du sie in den Namen einer Variablen oder Eigenschaft aufnehmen. Zum Beispiel ist timeMs ein viel eindeutigerer Name als time und temperatureC ist ein viel eindeutigerer Name als temperature. Punkt 37 beschreibt "Marken", die einen typsicheren Ansatz zur Modellierung von Einheiten bieten.

Dinge zum Erinnern

  • Vermeide die Wiederholung von Typinformationen in Kommentaren und Variablennamen. Im besten Fall ist es eine Verdoppelung der Typdeklarationen, im schlimmsten Fall führt es zu widersprüchlichen Informationen.

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

Punkt 31: Schiebe Nullwerte an den Rand deiner Typen

Wenn du zum ersten Mal strictNullChecks einschaltest, kann es dir so vorkommen, als müsstest du in deinem Code unzählige if-Anweisungen einfügen, die auf null und undefined Werte prüfen. Das liegt oft daran, dass die Beziehungen zwischen Null- und Nicht-Null-Werten implizit sind: Wenn Variable A nicht Null ist, weißt du, dass Variable B auch nicht Null ist und umgekehrt. Diese impliziten Beziehungen sind sowohl für die Leser deines Codes als auch für den Typprüfer verwirrend.

Es ist einfacher, mit Werten zu arbeiten, wenn sie entweder komplett null oder komplett nicht-null sind, als mit einer Mischung. Du kannst dies modellieren, indem du die Nullwerte an den Rand deiner Strukturen schiebst.

Angenommen, du willst das Minimum und Maximum einer Liste von Zahlen berechnen. Wir nennen das den "Umfang". Hier ist ein Versuch:

function extent(nums: number[]) {
  let min, max;
  for (const num of nums) {
    if (!min) {
      min = num;
      max = num;
    } else {
      min = Math.min(min, num);
      max = Math.max(max, num);
    }
  }
  return [min, max];
}

Der Code prüft den Typ (ohne strictNullChecks) und hat einen abgeleiteten Rückgabetyp von number[], was in Ordnung zu sein scheint. Aber er hat einen Fehler und einen Konstruktionsfehler:

  • Wenn der Minimal- oder Maximalwert Null ist, kann er überschrieben werden. Zum Beispiel gibt extent([0, 1, 2]) [1, 2] und nicht [0, 2] zurück.

  • Wenn das Array nums leer ist, gibt die Funktion [undefined, undefined] zurück. Diese Art von Objekt mit mehreren undefinedist für Kunden schwer zu handhaben und ist genau die Art von Typ, von der dieser Artikel abrät. Aus dem Quellcode wissen wir, dass min und max entweder beide undefined sind oder keines von beiden, aber diese Information ist nicht im Typensystem enthalten.

Wenn du strictNullChecks einschaltest, werden diese beiden Probleme deutlicher:

function extent(nums: number[]) {
  let min, max;
  for (const num of nums) {
    if (!min) {
      min = num;
      max = num;
    } else {
      min = Math.min(min, num);
      max = Math.max(max, num);
                  // ~~~ Argument of type 'number | undefined' is not
                  //     assignable to parameter of type 'number'
    }
  }
  return [min, max];
}

Der Rückgabetyp von extent wird nun als (number | undefined)[] abgeleitet, was den Designfehler deutlicher macht. Dies wird sich wahrscheinlich als Typfehler manifestieren, wo immer du extent aufrufst:

const [min, max] = extent([0, 1, 2]);
const span = max - min;
          // ~~~   ~~~ Object is possibly 'undefined'

Der Fehler in der Implementierung von extent kommt daher, dass du undefined als Wert für min ausgeschlossen hast, aber nicht max. Die beiden werden zusammen initialisiert, aber diese Information ist im Typsystem nicht vorhanden. Du könntest den Fehler beheben, indem du eine Prüfung für max hinzufügst, aber damit würdest du den Fehler nur noch verschlimmern.

Eine bessere Lösung ist es, das Minimum und das Maximum in dasselbe Objekt zu packen und dieses Objekt entweder vollständig null oder vollständig nichtnull zu machen:

function extent(nums: number[]) {
  let result: [number, number] | null = null;
  for (const num of nums) {
    if (!result) {
      result = [num, num];
    } else {
      result = [Math.min(num, result[0]), Math.max(num, result[1])];
    }
  }
  return result;
}

Der Rückgabetyp ist jetzt [number, number] | null, was für Kunden einfacher zu handhaben ist. Die Min- und Max-Werte können entweder mit einer Nicht-Null-Assertion abgefragt werden:

const [min, max] = extent([0, 1, 2])!;
const span = max - min;  // OK

oder einen einzelnen Scheck:

const range = extent([0, 1, 2]);
if (range) {
  const [min, max] = range;
  const span = max - min;  // OK
}

Durch die Verwendung eines einzigen Objekts zur Verfolgung des Umfangs haben wir unser Design verbessert, TypeScript geholfen, die Beziehung zwischen Null-Werten zu verstehen, und den Fehler behoben: Die Prüfung von if (!result) ist jetzt problemlos.

Eine Mischung aus Null- und Nicht-Null-Werten kann auch in Klassen zu Problemen führen. Nehmen wir zum Beispiel an, du hast eine Klasse, die sowohl einen Benutzer als auch seine Beiträge in einem Forum repräsentiert:

class UserPosts {
  user: UserInfo | null;
  posts: Post[] | null;

  constructor() {
    this.user = null;
    this.posts = null;
  }

  async init(userId: string) {
    return Promise.all([
      async () => this.user = await fetchUser(userId),
      async () => this.posts = await fetchPostsForUser(userId)
    ]);
  }

  getUserName() {
    // ...?
  }
}

Während die beiden Netzwerkanfragen geladen werden, werden die Eigenschaften user und posts null sein. Zu jeder Zeit können beide null sein, eine kann null sein, oder beide können nichtnull sein. Es gibt vier Möglichkeiten. Diese Komplexität wird in jede Methode der Klasse einfließen. Dieses Design wird mit ziemlicher Sicherheit zu Verwirrung, einer Vielzahl von null Prüfungen und Fehlern führen.

Ein besserer Entwurf würde warten, bis alle Daten, die von der Klasse verwendet werden, verfügbar sind:

class UserPosts {
  user: UserInfo;
  posts: Post[];

  constructor(user: UserInfo, posts: Post[]) {
    this.user = user;
    this.posts = posts;
  }

  static async init(userId: string): Promise<UserPosts> {
    const [user, posts] = await Promise.all([
      fetchUser(userId),
      fetchPostsForUser(userId)
    ]);
    return new UserPosts(user, posts);
  }

  getUserName() {
    return this.user.name;
  }
}

Jetzt ist die Klasse UserPosts vollständig nichtnull, und es ist einfach, korrekte Methoden für sie zu schreiben. Wenn du natürlich Operationen durchführen musst, während die Daten teilweise geladen sind, musst du dich mit der Vielzahl von null und nichtnull Zuständen auseinandersetzen.

(Lass dich nicht dazu verleiten, nullbare Eigenschaften durch Promises zu ersetzen. Das führt in der Regel zu noch verwirrenderem Code und zwingt alle deine Methoden dazu, asynchron zu sein. Promises verdeutlichen den Code, der Daten lädt, haben aber eher den gegenteiligen Effekt auf die Klasse, die diese Daten verwendet).

Dinge zum Erinnern

  • Vermeide Designs, in denen ein Wert, der null oder 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.

  • strictNullChecks kann zwar viele Probleme in deinem Code aufzeigen, ist aber unverzichtbar, wenn es darum geht, das Verhalten von Funktionen in Bezug auf Nullwerte aufzudecken.

Punkt 32: Bevorzuge Unionen von Schnittstellen gegenüber Schnittstellen von Gewerkschaften

Wenn du eine Schnittstelle erstellst, deren Eigenschaften Vereinigungstypen sind, solltest du dich fragen, ob der Typ als Vereinigung von genaueren Schnittstellen sinnvoller wäre.

Angenommen, du baust ein Vektorzeichenprogramm und möchtest eine Schnittstelle für Ebenen mit bestimmten Geometrietypen definieren:

interface Layer {
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}

Das Feld layout bestimmt, wie und wo die Formen gezeichnet werden (abgerundete Ecken? gerade?), während das Feld paint den Stil bestimmt (ist die Linie blau? dick? dünn? gestrichelt?).

Wäre es sinnvoll, eine Ebene zu haben, deren layout LineLayout ist, aber deren paint Eigenschaft FillPaint ist? Wahrscheinlich nicht. Diese Möglichkeit zuzulassen, macht die Benutzung der Bibliothek fehleranfälliger und erschwert die Arbeit mit dieser Schnittstelle.

Eine bessere Möglichkeit, dies zu modellieren, sind separate Schnittstellen für jede Art von Schicht:

interface FillLayer {
  layout: FillLayout;
  paint: FillPaint;
}
interface LineLayer {
  layout: LineLayout;
  paint: LinePaint;
}
interface PointLayer {
  layout: PointLayout;
  paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;

Indem du Layer auf diese Weise definierst, hast du die Möglichkeit von gemischten layout und paint Eigenschaften ausgeschlossen. Dies ist ein Beispiel dafür, dass du den Rat aus Artikel 28befolgst, Typen zu bevorzugen, die nur gültige Zustände darstellen.

Das häufigste Beispiel für dieses Muster ist die "tagged union" (oder "discriminated union"). In diesem Fall ist eine der Eigenschaften eine Vereinigung von String-Literaltypen:

interface Layer {
  type: 'fill' | 'line' | 'point';
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}

Wäre es sinnvoll, wie bisher type: 'fill' zu haben, aber dann eine LineLayout und PointPaint? Sicherlich nicht. Wandle Layer in eine Vereinigung von Schnittstellen um, um diese Möglichkeit auszuschließen:

interface FillLayer {
  type: 'fill';
  layout: FillLayout;
  paint: FillPaint;
}
interface LineLayer {
  type: 'line';
  layout: LineLayout;
  paint: LinePaint;
}
interface PointLayer {
  type: 'paint';
  layout: PointLayout;
  paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;

Die Eigenschaft type ist das "Tag" und kann verwendet werden, um zu bestimmen, mit welchem Typ von Layer du zur Laufzeit arbeitest. TypeScript kann auch den Typ von Layer anhand des Tags eingrenzen:

function drawLayer(layer: Layer) {
  if (layer.type === 'fill') {
    const {paint} = layer;  // Type is FillPaint
    const {layout} = layer;  // Type is FillLayout
  } else if (layer.type === 'line') {
    const {paint} = layer;  // Type is LinePaint
    const {layout} = layer;  // Type is LineLayout
  } else {
    const {paint} = layer;  // Type is PointPaint
    const {layout} = layer;  // Type is PointLayout
  }
}

Indem du die Beziehung zwischen den Eigenschaften in diesem Typ korrekt modellierst, hilfst du TypeScript, die Korrektheit deines Codes zu überprüfen. Derselbe Code, der die ursprüngliche Layer Definition enthält, wäre mit Type Assertions überladen gewesen.

Weil sie so gut mit dem Type-Checker von TypeScript funktionieren, sind Tagged Unions in TypeScript-Code allgegenwärtig. Erkenne dieses Muster und wende es an, wenn du kannst. Wenn du einen Datentyp in TypeScript mit einer Tagged Union darstellen kannst, ist es meist eine gute Idee, dies zu tun. Wenn du dir optionale Felder als eine Vereinigung ihres Typs und von undefined vorstellst, passen sie ebenfalls in dieses Muster. Betrachte diesen Typ:

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

Der Kommentar mit den Typinformationen ist ein deutliches Zeichen dafür, dass es ein Problem geben könnte(Punkt 30). Es gibt eine Beziehung zwischen den Feldern placeOfBirth und dateOfBirth, die du TypeScript nicht mitgeteilt hast.

Ein besserer Weg, dies zu modellieren, ist, diese beiden Eigenschaften in ein einziges Objekt zu verschieben. Das entspricht dem Verschieben der Werte von null in den Umkreis(Punkt 31):

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

Jetzt TypeScript beschwert sich über Werte mit einem Ort, aber keinem Geburtsdatum:

const alanT: Person = {
  name: 'Alan Turing',
  birth: {
// ~~~~ Property 'date' is missing in type
//      '{ place: string; }' but required in type
//      '{ place: string; date: Date; }'
    place: 'London'
  }
}

Außerdem muss eine Funktion, die ein Person Objekt annimmt, nur eine einzige Prüfung durchführen:

function eulogize(p: Person) {
  console.log(p.name);
  const {birth} = p;
  if (birth) {
    console.log(`was born on ${birth.date} in ${birth.place}.`);
  }
}

Wenn die Struktur des Typs außerhalb deiner Kontrolle liegt (z. B. wenn er von einer API kommt), kannst du die Beziehung zwischen diesen Feldern immer noch mit der bereits bekannten Vereinigung von Schnittstellen modellieren:

interface Name {
  name: string;
}

interface PersonWithBirth extends Name {
  placeOfBirth: string;
  dateOfBirth: Date;
}

type Person = Name | PersonWithBirth;

Jetzt bekommst du einige der gleichen Vorteile wie bei dem verschachtelten Objekt:

function eulogize(p: Person) {
  if ('placeOfBirth' in p) {
    p // Type is PersonWithBirth
    const {dateOfBirth} = p  // OK, type is Date
  }
}

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

Dinge zum Erinnern

  • Schnittstellen mit mehreren Eigenschaften, die Unionstypen sind, sind oft ein Fehler, weil sie die Beziehungen zwischen diesen Eigenschaften verschleiern.

  • Unions von Schnittstellen sind präziser und können von TypeScript verstanden werden.

  • Erwäge, ein "Tag" zu deiner Struktur hinzuzufügen, um die Kontrollflussanalyse von TypeScript zu erleichtern. Weil sie so gut unterstützt werden, sind "tagged unions" in TypeScript-Code allgegenwärtig.

Punkt 33: Genauere Alternativen zu String-Typen bevorzugen

Der Bereich des Typs string ist groß: "x" und "y" liegen darin, aber auch der gesamte Text von Moby Dick (er beginnt mit "Call me Ishmael…" und ist etwa 1,2 Millionen Zeichen lang). Wenn du eine Variable des Typs string deklarierst, solltest du dich fragen, ob ein engerer Typ besser geeignet wäre.

Angenommen, du baust eine Musiksammlung auf und möchtest einen Typ für ein Album definieren. Hier ist ein Versuch:

interface Album {
  artist: string;
  title: string;
  releaseDate: string;  // YYYY-MM-DD
  recordingType: string;  // E.g., "live" or "studio"
}

Die Häufigkeit der string Typen und die Typinformationen in den Kommentaren (siehe Punkt 30) sind starke Hinweise darauf, dass diese interface nicht ganz richtig ist. Hier ist was schief gehen kann:

const kindOfBlue: Album = {
  artist: 'Miles Davis',
  title: 'Kind of Blue',
  releaseDate: 'August 17th, 1959',  // Oops!
  recordingType: 'Studio',  // Oops!
};  // OK

Das Feld releaseDate ist falsch formatiert (laut Kommentar) und "Studio" wird großgeschrieben, obwohl es klein geschrieben werden sollte. Da es sich bei diesen Werten aber um Zeichenketten handelt, kann dieses Objekt Album zugewiesen werden und die Typüberprüfung beanstandet nichts.

Diese breiten string Typen können auch Fehler für gültige Album Objekte maskieren. Zum Beispiel:

function recordRelease(title: string, date: string) { /* ... */ }
recordRelease(kindOfBlue.releaseDate, kindOfBlue.title);  // OK, should be error

Beim Aufruf von recordRelease sind die Parameter vertauscht, aber beide sind Strings, so dass die Typüberprüfung keine Beanstandungen ergibt. Wegen der weiten Verbreitung von string Typen wird Code wie dieser manchmal als "stringly typed" bezeichnet.

Kannst du die Typen enger fassen, um diese Art von Problemen zu vermeiden? Der vollständige Text von Moby Dick wäre zwar ein schwerfälliger Künstlername oder Albumtitel, aber er ist zumindest plausibel. Daher ist string für diese Felder geeignet. Für das Feld releaseDate ist es besser, einfach ein Date Objekt zu verwenden, um Probleme mit der Formatierung zu vermeiden. Für das Feld recordingType schließlich kannst du einen Union-Typ mit nur zwei Werten definieren (du könntest auch ein enum verwenden, aber ich empfehle generell, diese zu vermeiden; siehe Punkt 53):

type RecordingType = 'studio' | 'live';

interface Album {
  artist: string;
  title: string;
  releaseDate: Date;
  recordingType: RecordingType;
}

Mit diesen Änderungen ist TypeScript in der Lage, eine gründlichere Prüfung auf Fehler durchzuführen:

const kindOfBlue: Album = {
  artist: 'Miles Davis',
  title: 'Kind of Blue',
  releaseDate: new Date('1959-08-17'),
  recordingType: 'Studio'
// ~~~~~~~~~~~~ Type '"Studio"' is not assignable to type 'RecordingType'
};

Dieser Ansatz hat neben der strengeren Prüfung noch weitere Vorteile. Erstens stellt die explizite Definition des Typs sicher, dass seine Bedeutung nicht verloren geht, wenn er weitergegeben wird. Wenn du z. B. nur Alben eines bestimmten Aufnahmetyps finden möchtest, könntest du eine Funktion wie diese definieren:

function getAlbumsOfType(recordingType: string): Album[] {
  // ...
}

Woher soll der Aufrufer dieser Funktion wissen, was recordingType sein soll? Es ist nur ein string. Der Kommentar, der erklärt, dass es "studio" oder "live" ist, ist in der Definition von Album versteckt, wo der Benutzer vielleicht nicht nachschauen würde.

Zweitens kannst du durch die explizite Definition eines Typs eine Dokumentation zu diesem Typ hinzufügen (siehe Punkt 48):

/** What type of environment was this recording made in?  */
type RecordingType = 'live' | 'studio';

Wenn du getAlbumsOfType änderst, um eine RecordingType zu nehmen, kann sich der Anrufer durchklicken und die Dokumentation sehen (siehe Abbildung 4-1).

efts 04in01
Abbildung 4-1. Wenn du einen benannten Typ anstelle eines Strings 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 herauszieht. Die Underscore-Bibliothek nennt das "pluck":

function pluck(records, key) {
  return records.map(r => r[key]);
}

Wie würdest du das tippen? Hier ist ein erster Versuch:

function pluck(records: any[], key: string): any[] {
  return records.map(r => r[key]);
}

Dieser Typ prüft, ist aber nicht gut. Die any Typen sind problematisch, insbesondere beim Rückgabewert (siehe Punkt 38). Der erste Schritt zur Verbesserung der Typsignatur ist die Einführung eines generischen Typparameters:

function pluck<T>(records: T[], key: string): any[] {
  return records.map(r => r[key]);
                       // ~~~~~~ Element implicitly has an 'any' type
                       //        because type '{}' has no index signature
}

TypeScript beschwert sich jetzt, dass der string Typ für key zu breit ist. Und das zu Recht: Wenn du ein Array von Albumübergibst, gibt es nur vier gültige Werte für key ("artist", "title", "releaseDate" und "recordingType"), im Gegensatz zu der großen Menge an Strings. Das ist genau das, was der Typ keyof Album ist:

type K = keyof Album;
// Type is "artist" | "title" | "releaseDate" | "recordingType"

Die Lösung ist also, string durch keyof T zu ersetzen:

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

Das übersteht die Typprüfung. Wir haben TypeScript auch den Rückgabetyp ableiten lassen. Wie macht es das? Wenn du in deinem Editor mit der Maus über pluck fährst, wird der Typ abgeleitet:

function pluck<T>(record: T[], key: keyof T): T[keyof T][]

T[keyof T] ist der Typ eines jeden möglichen Wertes in T. Wenn du eine einzelne Zeichenkette als key übergibst, ist das zu weit gefasst. Zum Beispiel:

const releaseDates = pluck(albums, 'releaseDate'); // Type is (string | Date)[]

Der Typ sollte Date[] sein, nicht (string | Date)[]. keyof T ist zwar viel enger gefasst als string, aber immer noch zu breit. Um ihn weiter einzugrenzen, müssen wir einen zweiten generischen Parameter einführen, der eine Teilmenge von keyof T ist (wahrscheinlich ein einzelner Wert):

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

(Mehr über extends in diesem Zusammenhang findest du unter Punkt 14).

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

pluck(albums, 'releaseDate'); // Type is Date[]
pluck(albums, 'artist');  // Type is string[]
pluck(albums, 'recordingType');  // Type is RecordingType[]
pluck(albums, 'recordingDate');
           // ~~~~~~~~~~~~~~~ Argument of type '"recordingDate"' is not
           //                 assignable to parameter of type ...

Der Sprachendienst ist sogar in der Lage, die Tasten von Album automatisch zu vervollständigen (wie in Abbildung 4-2 gezeigt).

efts 04in02
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.

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 34: Unvollständige Typen gegenüber ungenauen Typen bevorzugen

Unter wirst du beim Schreiben von Typendeklarationen unweigerlich auf Situationen stoßen, in denen du das Verhalten auf eine präzisere oder weniger präzise Weise modellieren kannst. Präzision bei Typen ist im Allgemeinen eine gute Sache, denn sie hilft deinen Benutzern, Fehler zu finden und die Vorteile der Werkzeuge zu nutzen, die TypeScript bietet. Aber pass auf, wenn du die Präzision deiner Typendeklarationen erhöhst: Es ist leicht, Fehler zu machen, und falsche Typen können schlimmer sein als gar keine Typen.

Angenommen, du schreibst Typendeklarationen für GeoJSON, ein Format, das wir bereits in Punkt 31 kennengelernt haben. Eine GeoJSON Geometry kann einer von mehreren Typen sein, die jeweils unterschiedlich geformte Koordinatenfelder haben:

interface Point {
  type: 'Point';
  coordinates: number[];
}
interface LineString {
  type: 'LineString';
  coordinates: number[][];
}
interface Polygon {
  type: 'Polygon';
  coordinates: number[][][];
}
type Geometry = Point | LineString | Polygon;  // Also several others

Das ist in Ordnung, aber number[] für eine Koordinate ist ein bisschen ungenau. In Wirklichkeit handelt es sich um Breiten- und Längengrade, daher wäre ein Tupel-Typ vielleicht besser:

type GeoPosition = [number, number];
interface Point {
  type: 'Point';
  coordinates: GeoPosition;
}
// Etc.

Du veröffentlichst deine präziseren Typen in der Welt und wartest auf die Lobeshymnen, die dir entgegenschlagen. Leider beschwert sich ein Nutzer, dass deine neuen Typen alles kaputt gemacht haben. Auch wenn du bisher nur Längen- und Breitengrade verwendet hast, darf eine Position in GeoJSON ein drittes Element, eine Höhe und möglicherweise noch mehr haben. Bei dem Versuch, die Typendeklarationen genauer zu machen, bist du zu weit gegangen und hast die Typen ungenau gemacht! Wenn du deine Typdeklarationen weiter verwenden willst, musst du Typ-Assertions einführen oder den Typ-Checker mit as any ganz ausschalten.

Ein weiteres Beispiel ist der Versuch, Typendeklarationen für eine Lisp-ähnliche Sprache zu schreiben, die in JSON definiert ist:

12
"red"
["+", 1, 2]  // 3
["/", 20, 2]  // 10
["case", [">", 20, 10], "red", "blue"]  // "red"
["rgb", 255, 0, 127]  // "#FF007F"

Die Bibliothek Mapbox verwendet ein solches System, um das Aussehen von Kartenmerkmalen auf vielen Geräten zu bestimmen. Es gibt ein ganzes Spektrum an Präzision, mit der du versuchen könntest, dies zu schreiben:

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

Darüber hinaus solltest du eine Testmenge von Ausdrücken einführen, die gültig sind und Ausdrücke, die nicht gültig sind. Wenn du deine Typen präziser machst, hilft das, Regressionen zu vermeiden (siehe Punkt 52):

const tests: Expression2[] = [
  10,
  "red",
  true,
// ~~~ Type 'true' is not assignable to type 'Expression2'
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],  // Too many values
  ["**", 2, 31],  // Should be an error: no "**" function
  ["rgb", 255, 128, 64],
  ["rgb", 255, 0, 127, 0]  // Too many values
];

Um die nächste Präzisionsstufe zu erreichen, kannst du eine Vereinigung von String-Literalen als erstes Element eines Tupels verwenden:

type FnName = '+' | '-' | '*' | '/' | '>' | '<' | 'case' | 'rgb';
type CallExpression = [FnName, ...any[]];
type Expression3 = number | string | CallExpression;

const tests: Expression3[] = [
  10,
  "red",
  true,
// ~~~ Type 'true' is not assignable to type 'Expression3'
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],
  ["**", 2, 31],
// ~~~~~~~~~~~ Type '"**"' is not assignable to type 'FnName'
  ["rgb", 255, 128, 64],
  ["rgb", 255, 0, 127, 0]  // Too many values
];

Es gibt einen neuen gefangenen Fehler und keine Rückschritte. Ziemlich gut!

Was wenn du sicherstellen willst, dass jede Funktion die richtige Anzahl von Argumenten erhält? Das wird schwieriger, da der Typ nun rekursiv sein muss, um alle Funktionsaufrufe zu erreichen. TypeScript erlaubt das, allerdings müssen wir den Type Checker davon überzeugen, dass unsere Rekursion nicht unendlich ist. In diesem Fall bedeutet das, dass wir CaseCall (das ein Array mit gerader Länge sein muss) mit interface statt mit type definieren. Das ist möglich, wenn auch etwas umständlich:

type Expression4 = number | string | CallExpression;

type CallExpression = MathCall | CaseCall | RGBCall;

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

interface CaseCall {
  0: 'case';
  1: Expression4;
  2: Expression4;
  3: Expression4;
  4?: Expression4;
  5?: Expression4;
  // etc.
  length: 4 | 6 | 8 | 10 | 12 | 14 | 16; // etc.
}

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

const tests: Expression4[] = [
  10,
  "red",
  true,
// ~~~ Type 'true' is not assignable to type 'Expression4'
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],
// ~~~~~~                                ~~~~~~~
// Type '"case"' is not assignable to type '"rgb"'.
//          Type 'string' is not assignable to type 'undefined'.
  ["**", 2, 31],
// ~~~~ Type '"**"' is not assignable to type '"+" | "-" | "/" | ...
  ["rgb", 255, 128, 64],
  ["rgb", 255, 0, 127, 0]
  //                   ~ Type 'number' is not assignable to type 'undefined'.
];

Jetzt führen alle ungültigen Ausdrücke zu Fehlern. Und es ist interessant, dass du etwas wie "ein Array mit gerader Länge" mit einem TypeScript interface ausdrücken kannst. Aber einige dieser Fehlermeldungen sind nicht sehr gut, insbesondere die, dass "case" nicht "rgb" zugewiesen werden kann.

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

Die Komplexität dieser Typendeklaration hat auch die Wahrscheinlichkeit erhöht, dass sich ein Fehler einschleicht. Zum Beispiel verlangt Expression4, dass alle mathematischen Operatoren zwei Parameter annehmen, aber die Mapbox-Ausdrucksspezifikation sagt, dass + und * mehr Parameter annehmen können. Außerdem kann - einen einzigen Parameter annehmen, in diesem Fall negiert er seine Eingabe. Expression4 zeigt in all diesen Fällen fälschlicherweise Fehler an:

const okExpressions: Expression4[] = [
  ['-', 12],
// ~~~ Type '"-"' is not assignable to type '"rgb"'.
  ['+', 1, 2, 3],
// ~~~ Type '"+"' is not assignable to type '"rgb"'.
  ['*', 2, 3, 4],
// ~~~ Type '"*"' is not assignable to type '"rgb"'.
];

Wieder einmal sind wir bei dem Versuch, genauer zu sein, über das Ziel hinausgeschossen und ungenau geworden. Diese Ungenauigkeiten können korrigiert werden, aber du solltest deine Tests ausweiten, um dich davon zu überzeugen, dass du nichts anderes übersehen hast. Komplexer Code erfordert in der Regel mehr Tests, und das Gleiche gilt für Typen.

Wenn du Typen verfeinerst, kann es hilfreich sein, an die Metapher des "unheimlichen Tals" zu denken. Die Verfeinerung von sehr ungenauen Typen wie any ist normalerweise hilfreich. Aber je präziser die Typen werden, desto höher ist die Erwartung, dass sie auch genau sind. Du verlässt dich dann immer mehr auf die Typen, und Ungenauigkeiten führen zu größeren Problemen.

Dinge zum Erinnern

  • Vermeide das unheimliche Tal der Typsicherheit: Falsche Typen sind oft schlimmer als keine Typen.

  • Wenn du einen Typ nicht genau modellieren kannst, dann modelliere ihn nicht ungenau! Bestätige die Lücken mit any oder unknown.

  • Achte auf Fehlermeldungen und Autovervollständigung, wenn du die Eingaben immer genauer machst. Es geht nicht nur um Korrektheit, sondern auch um die Erfahrung der Entwickler.

Punkt 35: Typen aus APIs und Specs generieren, nicht aus Daten

Die anderen Artikel in diesem Kapitel haben die vielen Vorteile eines guten Typendesigns erörtert und gezeigt, was schiefgehen kann, wenn du es nicht tust. Ein gut gestalteter Typ macht die Verwendung von TypeScript zu einem Vergnügen, während ein schlecht gestalteter Typ die Verwendung miserabel machen kann. Das setzt das Typendesign allerdings ziemlich unter Druck. Wäre es nicht schön, wenn du das nicht selbst tun müsstest?

Zumindest einige deiner Typen werden wahrscheinlich von außerhalb deines Programms kommen: Dateiformate, APIs oder Spezifikationen. In diesen Fällen kannst du das Schreiben von Typen vielleicht vermeiden, indem du sie stattdessen generierst. In diesem Fall ist es wichtig, dass du Typen aus Spezifikationen und nicht aus Beispieldaten generierst. Wenn du Typen aus einer Spezifikation generierst, stellt TypeScript sicher, dass du keine Fälle übersehen hast. Wenn du Typen aus Daten generierst, berücksichtigst du nur die Beispiele, die du gesehen hast. Dabei könntest du wichtige Kanten übersehen, die dein Programm kaputt machen könnten.

In Punkt 31 haben wir eine Funktion geschrieben, um die Bounding Box eines GeoJSON Features zu berechnen. So sah das aus:

function calculateBoundingBox(f: GeoJSONFeature): BoundingBox | null {
  let box: BoundingBox | null = null;

  const helper = (coords: any[]) => {
    // ...
  };

  const {geometry} = f;
  if (geometry) {
    helper(geometry.coordinates);
  }

  return box;
}

Der Typ GeoJSONFeature wurde nie explizit definiert. Du könntest ihn mit einigen der Beispiele aus Punkt 31 schreiben. Ein besserer Ansatz ist es jedoch, die formale GeoJSON-Spezifikation zu verwenden.1 Zum Glück für uns gibt es bereits TypeScript-Typendeklarationen dafür auf DefinitelyTyped. Du kannst sie auf die übliche Weise hinzufügen:

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

Wenn du die GeoJSON-Deklarationen einfügst, zeigt TypeScript sofort einen Fehler an:

import {Feature} from 'geojson';

function calculateBoundingBox(f: Feature): BoundingBox | null {
  let box: BoundingBox | null = null;

  const helper = (coords: any[]) => {
    // ...
  };

  const {geometry} = f;
  if (geometry) {
    helper(geometry.coordinates);
                 // ~~~~~~~~~~~
                 // Property 'coordinates' does not exist on type 'Geometry'
                 //   Property 'coordinates' does not exist on type
                 //   'GeometryCollection'
  }

  return box;
}

Das Problem ist, dass dein Code davon ausgeht, dass eine Geometrie eine coordinates Eigenschaft hat. Das trifft auf viele Geometrien zu, darunter Punkte, Linien und Polygone. Aber eine GeoJSON-Geometrie kann auch eine GeometryCollection sein, eine heterogene Sammlung von anderen Geometrien. Im Gegensatz zu den anderen Geometrietypen hat sie keine coordinates Eigenschaft.

Wenn du calculateBoundingBox für ein Feature aufrufst, dessen Geometrie GeometryCollection ist, wird eine Fehlermeldung ausgegeben, dass die Eigenschaft 0 von undefined nicht gelesen werden kann. Das ist ein echter Fehler! Und wir haben ihn mithilfe von Typdefinitionen aus einer Spezifikation gefunden.

Eine Möglichkeit, dies zu beheben, ist, GeometryCollections ausdrücklich zu verbieten, wie hier gezeigt:

const {geometry} = f;
if (geometry) {
  if (geometry.type === 'GeometryCollection') {
    throw new Error('GeometryCollections are not supported.');
  }
  helper(geometry.coordinates);  // OK
}

TypeScript ist in der Lage, den Typ von geometry anhand der Prüfung zu verfeinern, sodass der Verweis auf geometry.coordinates zulässig ist. Dies führt zumindest zu einer klareren Fehlermeldung für den Benutzer.

Aber die bessere Lösung ist, alle Arten von Geometrie zu unterstützen! Das kannst du erreichen, indem du eine weitere Hilfsfunktion herausziehst:

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

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

Hättest du die Typdeklarationen für GeoJSON selbst geschrieben, hättest du sie auf der Grundlage deiner Kenntnisse und Erfahrungen mit dem Format erstellt. Dies hätte möglicherweise GeometryCollectionnicht berücksichtigt und zu einem falschen Gefühl der Sicherheit bezüglich der Korrektheit deines Codes geführt. Die Verwendung von Typen, die auf einer Spezifikation basieren, gibt dir die Sicherheit, dass dein Code mit allen Werten funktioniert, nicht nur mit denen, die du kennst.

Ähnliche Überlegungen gelten für API-Aufrufe: Wenn du Typen aus der Spezifikation einer API generieren kannst, ist es in der Regel eine gute Idee, dies zu tun. Das funktioniert besonders gut bei APIs, die selbst typisiert sind, wie z. B. GraphQL.

Eine GraphQL-API wird mit einem Schema ausgeliefert, das alle möglichen Abfragen und Schnittstellen mit einem Typsystem ähnlich wie TypeScript spezifiziert. Du schreibst Abfragen, die bestimmte Felder in diesen Schnittstellen abfragen. Um zum Beispiel Informationen über ein Repository mit der GitHub GraphQL API zu erhalten, könntest du schreiben:

query {
  repository(owner: "Microsoft", name: "TypeScript") {
    createdAt
    description
  }
}

Das Ergebnis ist:

{
  "data": {
    "repository": {
      "createdAt": "2014-06-17T15:28:39Z",
      "description":
        "TypeScript is a superset of JavaScript that compiles to JavaScript."
    }
  }
}

Das Schöne an diesem Ansatz ist, dass du TypeScript-Typen für deine spezifische Abfrage generieren kannst. Wie beim GeoJSON-Beispiel kannst du so sicherstellen, dass du die Beziehungen zwischen den Typen und ihre Nullbarkeit genau modellierst.

Hier ist eine Abfrage, um die Open-Source-Lizenz für ein GitHub-Repository zu erhalten:

query getLicense($owner:String!, $name:String!){
  repository(owner:$owner, name:$name) {
    description
    licenseInfo {
      spdxId
      name
    }
  }
}

$owner und $name sind GraphQL-Variablen, die selbst typisiert sind. Die Typsyntax ist TypeScript so ähnlich, dass es verwirrend sein kann, hin und her zu gehen. String ist ein GraphQL-Typ - in TypeScript wäre es string (siehe Punkt 10). Und während TypeScript-Typen nicht löschbar sind, sind Typen in GraphQL löschbar. Das ! hinter dem Typ zeigt an, dass er garantiert nicht null ist.

Es gibt viele Tools, die dir helfen, von einer GraphQL-Abfrage zu TypeScript-Typen zu gelangen. Eines davon ist Apollo. Hier erfährst du, wie du es benutzt:

$ apollo client:codegen \
    --endpoint https://api.github.com/graphql \
    --includes license.graphql \
    --target typescript
Loading Apollo Project
Generating query files with 'typescript' target - wrote 2 files

Du brauchst ein GraphQL-Schema, um Typen für eine Abfrage zu erzeugen. Apollo holt es sich vom Endpunkt api.github.com/graphql. Die Ausgabe sieht wie folgt aus:

export interface getLicense_repository_licenseInfo {
  __typename: "License";
  /** Short identifier specified by <https://spdx.org/licenses> */
  spdxId: string | null;
  /** The license full name specified by <https://spdx.org/licenses> */
  name: string;
}

export interface getLicense_repository {
  __typename: "Repository";
  /** The description of the repository. */
  description: string | null;
  /** The license associated with the repository */
  licenseInfo: getLicense_repository_licenseInfo | null;
}

export interface getLicense {
  /** Lookup a given repository by the owner and repository name. */
  repository: getLicense_repository | null;
}

export interface getLicenseVariables {
  owner: string;
  name: string;
}

Die wichtigsten Punkte sind hier zu beachten:

  • Schnittstellen werden sowohl für die Abfrageparameter (getLicenseVariables) als auch für die Antwort (getLicense) erstellt.

  • Die Informationen zur Löschbarkeit werden vom Schema an die Antwortschnittstellen übertragen. Die Felder repository, description, licenseInfo und spdxId sind löschbar, während die Lizenz name und die Abfragevariablen nicht löschbar sind.

  • Die Dokumentation wird als JSDoc übertragen, damit sie in deinem Editor erscheint(Punkt 48). Diese Kommentare stammen aus dem GraphQL-Schema selbst.

Diese Typinformationen helfen dir, die API richtig zu nutzen. Wenn sich deine Abfragen ändern, werden sich auch die Typen ändern. Wenn sich das Schema ändert, ändern sich auch deine Typen. Es besteht keine Gefahr, dass deine Typen und die Realität auseinanderklaffen, da sie beide aus einer einzigen Quelle der Wahrheit stammen: dem GraphQL-Schema.

Was ist, wenn es keine Spezifikation oder kein offizielles Schema gibt? Dann musst du Typen aus Daten generieren. Tools wie quicktype können dabei helfen. Aber sei dir bewusst, dass deine Typen möglicherweise nicht mit der Realität übereinstimmen: Es kann Kanten geben, die du übersehen hast.

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

Dinge zum Erinnern

  • Ziehe in Erwägung, Typen für API-Aufrufe und Datenformate zu generieren, um Typsicherheit bis an die Kanten deines Codes zu erreichen.

  • Erstelle lieber Code aus Spezifikationen als aus Daten. Seltene Fälle sind wichtig!

Punkt 36: Benenne Typen in der Sprache deines Problembereichs

In der Informatik gibt es nur zwei schwierige Probleme: die Ungültigmachung von Caches und die Benennung von Dingen.

Phil Karlton

Dieses Buch hat viel über die Form von Typen und die Wertemengen in ihren Domänen gesagt, aber viel weniger darüber, wie du deine Typen benennst. Aber auch das ist ein wichtiger Teil des Typendesigns. Gut gewählte Typen-, Eigenschafts- und Variablennamen können die Absicht verdeutlichen und die Abstraktionsebene deines Codes und deiner Typen erhöhen. Schlecht gewählte Typen können deinen Code verwirren und zu falschen mentalen Modellen führen.

Angenommen, du baust eine Datenbank mit Tieren auf. Du erstellst eine Schnittstelle, die ein Tier repräsentiert:

interface Animal {
  name: string;
  endangered: boolean;
  habitat: string;
}

const leopard: Animal = {
  name: 'Snow Leopard',
  endangered: false,
  habitat: 'tundra',
};

Hier gibt es ein paar Probleme:

  • name ist ein sehr allgemeiner Begriff. Was für einen Namen erwartest du? Einen wissenschaftlichen Namen? Einen gewöhnlichen Namen?

  • Auch das boolesche Feld endangered ist nicht eindeutig. Was ist, wenn ein Tier vom Aussterben bedroht ist? Ist die Absicht hier "gefährdet oder schlimmer"? Oder bedeutet es wörtlich "gefährdet"?

  • Das Feld habitat ist sehr zweideutig, nicht nur wegen des zu weit gefassten string Typs(Punkt 33), 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, ein ConservationStatus Typ, der 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 die Firma verlassen oder erinnert sich nicht mehr. Schlimmer noch: Du könntest git blame aufrufen, um herauszufinden, wer diese lausigen Typen geschrieben hat, nur um festzustellen, dass du es warst!

Mit der zweiten Version hat sich die Situation deutlich verbessert. Wenn du mehr über das Köppen-Klimaklassifizierungssystem erfahren oder herausfinden willst, was ein Schutzstatus genau bedeutet, findest du im Internet unzählige Ressourcen, die dir helfen.

Jeder Bereich hat ein spezielles Vokabular, um sein Thema zu beschreiben. Anstatt deine eigenen Begriffe zu erfinden, solltest du versuchen, Begriffe aus dem Bereich deines Problems zu verwenden. Diese Vokabeln wurden oft über Jahre, Jahrzehnte oder Jahrhunderte hinweg entwickelt und werden von den Fachleuten gut verstanden. Wenn du diese Begriffe verwendest, kannst du besser mit den Nutzern kommunizieren und die Klarheit deiner Texte erhöhen.

Achte darauf, dass du das Fachvokabular korrekt verwendest: Wenn du die Sprache eines Fachgebiets übernimmst, um etwas anderes zu meinen, ist das noch verwirrender, als wenn du dein eigenes erfindest.

Hier sind noch ein paar andere Regeln, die du beim Benennen von Typen, Eigenschaften undVariablen beachten solltest:

  • Mache Unterscheidungen sinnvoll. Beim Schreiben und Sprechen kann es ermüdend sein, immer wieder das gleiche Wort zu verwenden. Wir führen Synonyme ein, um die Monotonie zu durchbrechen. Das macht das Lesen von Prosa angenehmer, aber im Code hat es den gegenteiligen Effekt. Wenn du zwei verschiedene Begriffe verwendest, achte darauf, dass du eine sinnvolle Unterscheidung triffst. Wenn nicht, solltest du denselben Begriff verwenden.

  • Vermeide vage, nichtssagende Namen wie "Daten", "Info", "Ding", "Gegenstand", "Objekt" oder das allseits beliebte "Entität". Wenn "Entität" in deinem Bereich eine bestimmte Bedeutung hat, ist das in Ordnung. Wenn du ihn aber nur verwendest, weil dir kein aussagekräftigerer Name einfällt, wirst du irgendwann Probleme bekommen.

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

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

Punkt 37: Erwäge "Marken" für die Nominaltypisierung

Unter Punkt 4 ging es um strukturelle ("Ente") Typisierung und wie sie manchmal zu überraschenden Ergebnissen führen kann:

interface Vector2D {
  x: number;
  y: number;
}
function calculateNorm(p: Vector2D) {
  return Math.sqrt(p.x * p.x + p.y * p.y);
}

calculateNorm({x: 3, y: 4});  // OK, result is 5
const vec3D = {x: 3, y: 4, z: 1};
calculateNorm(vec3D);  // OK! result is also 5

Was, wenn du möchtest, dass calculateNorm 3D-Vektoren ablehnt? Das verstößt gegen das strukturelle Typisierungsmodell von TypeScript, ist aber mathematisch sicherlich korrekter.

Eine Möglichkeit, dies zu erreichen, ist die nominale Typisierung. Bei der Nominaltypisierung ist ein Wert ein Vector2D, weil du es sagst, nicht weil er die richtige Form hat. Um dies in TypeScript anzunähern, kannst du eine "Marke" einführen (denk an Kühe, nicht an Coca-Cola):

interface Vector2D {
  _brand: '2d';
  x: number;
  y: number;
}
function vec2D(x: number, y: number): Vector2D {
  return {x, y, _brand: '2d'};
}
function calculateNorm(p: Vector2D) {
  return Math.sqrt(p.x * p.x + p.y * p.y);  // Same as before
}

calculateNorm(vec2D(3, 4)); // OK, returns 5
const vec3D = {x: 3, y: 4, z: 1};
calculateNorm(vec3D);
           // ~~~~~ Property '_brand' is missing in type...

Die Marke stellt sicher, dass der Vektor von der richtigen Stelle stammt. Zugegeben, nichts hält dich davon ab, _brand: '2d' zum Wert vec3D hinzuzufügen. Aber hier wird aus dem Versehen eine bösartige Handlung. Diese Art von Branding reicht in der Regel aus, um den versehentlichen Missbrauch von Funktionen zu erkennen.

Interessanterweise kannst du viele der gleichen Vorteile wie explizite Marken nutzen, wenn du nur im Typsystem arbeitest. Dadurch entfällt der Laufzeit-Overhead und du kannst auch eingebaute Typen wie string oder number markieren, denen du keine zusätzlichen Eigenschaften zuweisen kannst.

Was ist zum Beispiel, wenn du eine Funktion hast, die auf dem Dateisystem arbeitet und einen absoluten (im Gegensatz zu einem relativen) Pfad benötigt? Das lässt sich zur Laufzeit leicht überprüfen (beginnt der Pfad mit "/"?), aber nicht so leicht im Typsystem.

Hier ist ein Ansatz mit Marken:

type AbsolutePath = string & {_brand: 'abs'};
function listAbsolutePath(path: AbsolutePath) {
  // ...
}
function isAbsolutePath(path: string): path is AbsolutePath {
  return path.startsWith('/');
}

Du kannst kein Objekt konstruieren, das ein string ist und eine _brand Eigenschaft hat. Das ist ein reines Spiel mit dem Typensystem.

Wenn du einen string Pfad hast, der entweder absolut oder relativ sein kann, kannst du ihn mit dem type guard überprüfen, der seinen Typ verfeinert:

function f(path: string) {
  if (isAbsolutePath(path)) {
    listAbsolutePath(path);
  }
  listAbsolutePath(path);
                // ~~~~ Argument of type 'string' is not assignable
                //      to parameter of type 'AbsolutePath'
}

Diese Art von Ansatz könnte hilfreich sein, um zu dokumentieren, welche Funktionen absolute oder relative Pfade erwarten und welche Art von Pfad jede Variable enthält. Es ist allerdings keine hundertprozentige Garantie: path as AbsolutePath wird für jede string erfolgreich sein. Aber wenn du diese Art von Aussagen vermeidest, ist die einzige Möglichkeit, eine AbsolutePath zu bekommen, dass du sie bekommst oder dass du sie überprüfst, was genau das ist, was du willst.

Mit diesem Ansatz lassen sich viele Eigenschaften modellieren, die nicht im Typsystem ausgedrückt werden können. Zum Beispiel die binäre Suche, um ein Element in einer Liste zu finden:

function binarySearch<T>(xs: T[], x: T): boolean {
  let low = 0, high = xs.length - 1;
  while (high >= low) {
    const mid = low + Math.floor((high - low) / 2);
    const v = xs[mid];
    if (v === x) return true;
    [low, high] = x > v ? [mid + 1, high] : [low, mid - 1];
  }
  return false;
}

Das funktioniert, wenn die Liste sortiert ist, führt aber zu falsch-negativen Ergebnissen, wenn sie es nicht ist. Du kannst eine sortierte Liste nicht im Typsystem von TypeScript darstellen. Aber du kannst eine Marke erstellen:

type SortedList<T> = T[] & {_brand: 'sorted'};

function isSorted<T>(xs: T[]): xs is SortedList<T> {
  for (let i = 1; i < xs.length; i++) {
    if (xs[i] < xs[i - 1]) {
      return false;
    }
  }
  return true;
}

function binarySearch<T>(xs: SortedList<T>, x: T): boolean {
  // ...
}

Um diese Version von binarySearch aufzurufen, musst du entweder eine SortedList bekommen (d.h. einen Beweis haben, dass die Liste sortiert ist) oder selbst mit isSorted beweisen, dass sie sortiert ist. Der lineare Scan ist zwar nicht toll, aber wenigstens bist du sicher!

Dies ist eine hilfreiche Perspektive für die Typüberprüfung im Allgemeinen. Um zum Beispiel eine Methode für ein Objekt aufzurufen, musst du entweder ein Objekt erhalten, das nichtnull ist, oder selbst mit einer Bedingung beweisen, dass es nichtnull ist.

Du kannst auch number Typen markieren - zum Beispiel, um Einheiten anzubringen:

type Meters = number & {_brand: 'meters'};
type Seconds = number & {_brand: 'seconds'};

const meters = (m: number) => m as Meters;
const seconds = (s: number) => s as Seconds;

const oneKm = meters(1000);  // Type is Meters
const oneMin = seconds(60);  // Type is Seconds

Das kann in der Praxis unangenehm sein, denn durch Rechenoperationen vergessen die Zahlen ihre Marken:

const tenKm = oneKm * 10;  // Type is number
const v = oneKm / oneMin;  // Type is number

Wenn dein Code jedoch viele Zahlen mit gemischten Einheiten enthält, kann dies immer noch ein attraktiver Ansatz sein, um die erwarteten Arten von numerischen Parametern zu dokumentieren.

Dinge zum Erinnern

  • TypeScript verwendet die strukturelle Typisierung ("Duck"), die manchmal zu überraschenden Ergebnissen führen kann. Wenn du nominale Typisierung brauchst, solltest du deine Werte mit "Marken" versehen, um sie zu unterscheiden.

  • In manchen Fällen ist es möglich, Marken vollständig im Typsystem und nicht zur Laufzeit zuzuordnen. Du kannst diese Technik verwenden, um Eigenschaften außerhalb des TypeScript-Typensystems zu modellieren.

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

Get Effektives TypeScript now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.