Startseite / Artikel

Sechs TypeScript-Techniken, die Typen in echte Fehlerverhinderungsmittel verwandeln

Erfahren Sie, wie satisfied, tagged Unions, never Checks, unknown-Werte, abgeleitete Typen sowie branded IDs es TypeScript ermöglichen, echte Fehler bereits zur Kompilierzeit aufzudecken – anstatt erst in der Produktion.

Verlag: REACTAPP.TOP7 Min. Lesezeit1627 Wörter

Quellen: TypeScript Can Already Do This. Almost Nobody Uses It.

Ein Projekt kann TypeScript in package.json auflisten, Schnittstellen mit Dutzenden optionaler Felder deklarieren und dennoch mit dem Fehler „cannot read properties of undefined“ abstürzen. Dass der Compiler installiert ist, bedeutet noch nicht, dass er auch für einen funktioniert – und das Hinzufügen von : string zu jeder Variablen allein hilft nur wenig. Die sechs Techniken unten sind es, die erfahrene Teams nutzen, um dem Typüberprüfer tatsächlich Gewicht zu verleihen; jede davon wird mit einem Beispielcode sowie der dahinterstehenden Begründung dargestellt.

Objekte mit satisfies statt einer Annotation validieren

Die übliche Methode, um sicherzustellen, dass ein Konfigurationsobjekt die richtige Struktur hat, besteht darin, es mit einer Annotation zu versehen:

const config: Record<string, number> = {
  timeout: 3000,
  retries: 3,
};
config.timeout.toFixed(2); // fine

Das kompiliert zwar, doch die Anmerkung ersetzt alles, was TypeScript ursprünglich geschlossen hätte. Der Typ der Variablen ist nun exakt Record<string, number>, wodurch der Compiler nicht mehr weiß, welche Schlüssel das Objekt tatsächlich enthält. config.timeout ist lediglich „eine Zahl unter einem bestimmten String-Schlüssel“, ein Tippfehler wie config.timout wird ohne Probleme akzeptiert, und bei aktivierter noUncheckedIndexedAccess wird jeder gelesene Eigenschaftswert zu number | undefined.

Der satisfies-Operator, der seit TypeScript 4.9 verfügbar ist, überprüft das Objekt gegen den Typ, ohne den ursprünglich geschlossenen Typ zu ersetzen:

const config = {
  timeout: 3000,
  retries: 3,
} satisfies Record<string, number>;

config.timeout; // TypeScript still knows this is literally 3000

Es erfolgt eine Validierung (eine Zeichenkettenwert würde abgelehnt), und die genaue Struktur bleibt erhalten: TypeScript weiß, dass das Objekt genau timeout und retries enthält, wobei falsch geschriebene Schlüssel zu Fehlern führen. Eine Korrektur einer gängigen Annahme, einschließlich des Kommentars im Beispielcode: satisfies allein bewahrt timeout nicht als Literaltyp 3000. Eigenschaften eines veränderbaren Objekts werden weiterhin auf number erweitert. Wenn auch die Literaltwerte benötigt werden, sollten die beiden Operator kombiniert werden zu as const satisfies Record<string, number>, wodurch das Objekt lesbar gemacht und jeder Literaltyp beibehalten wird. Viele Entwickler greifen weiterhin auf diese Annotation zurück, einfach weil sie jahrelang die einzige Option war.

Modellvarianten als markierte Unionen, nicht als optionale Felder

Wenn ein Wert verschiedene Formen annehmen kann, ist es verlockend, jedes Feld optional zu machen:

interface ApiResponse {
  data?: User[];
  error?: string;
  loading?: boolean;
}

Der Typ erlaubt nun jede Kombination, einschließlich von data und error zusammen oder keines davon bei loading false. Die Regel, dass nur ein Zustand gleichzeitig gilt, befindet sich im Kopf der Menschen, und der Compiler kann sie nicht durchsetzen.

Eine diskriminierte Union (gekennzeichnete Union) kodiert diese Regel. Jede Variante trägt ein literales status-Tag sowie nur die zu ihr gehörenden Felder:

type ApiResponse =
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string };

function render(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserList users={response.data} />; // data exists, guaranteed
    case 'error':
      return <ErrorBanner message={response.error} />;
  }
}

In jedem case beschränkt TypeScript den Wert von response auf die entsprechende Variante. response.data ist nur im success-Fall erreichbar, da die anderen Varianten überhaupt keine data-Eigenschaft besitzen. Eine ganze Kategorie von Fehlern vom Typ „Ist das hier definiert?“ wird bereits vor dem Ausführen des Codes beseitigt, und dasselbe Muster gilt für Reducer-Aktionen, Formzustände sowie API-Ergebnisse.

Sorgen dafür, dass fehlende Fälle den Build scheitern lassen

Tagge Unionen weisen noch eine Lücke auf. Angenommen, Monate später fügt jemand einen Status 'forbidden' hinzu. Es gibt nichts, was dazu zwingt, jeden switch über ApiResponse zu aktualisieren, sodass der neue Fall stillschweigend weiterläuft und als Fehler in der Produktion auftaucht.

Eine Vollständigkeitsprüfung schließt diese Lücke. Der default-Zweig weist den eingegrenzten Wert einer Variablen mit dem Typ never zu:

function render(response: ApiResponse) {
  switch (response.status) {
    case 'loading':
      return <Spinner />;
    case 'success':
      return <UserList users={response.data} />;
    case 'error':
      return <ErrorBanner message={response.error} />;
    default:
      const exhaustiveCheck: never = response;
      throw new Error(`Unhandled status: ${exhaustiveCheck}`);
  }
}

Sobald jede Variante behandelt wurde, bleibt für response nichts mehr im default-Zweig übrig, wodurch sein Typ never ist und die Zuweisung kompiliert. Fügt man 'forbidden' ohne entsprechenden case-Eintrag hinzu, wird response dort zu { status: 'forbidden' }, was nicht an einen Typ von never zugewiesen werden kann, wodurch der Build fehlschlägt und direkt auf den vergessenen Zweig hinweist. throw schützt außerdem im Laufzeitbetrieb, falls dennoch unerwartete Daten eintreffen. Das Einrahmen des default-Körpers in Klammern hält Linter zufrieden, die Deklarationen innerhalb von case-Klauseln markieren.

Verwenden Sie unknown an den Rändern Ihrer Anwendung

any deaktiviert die Typüberprüfung. unknown lässt sie aktiv und zwingt Sie dazu, vor der Verwendung nachzuweisen, was ein Wert ist. Genau dieses Verhalten möchten Sie dort haben, wo unzuverlässige Daten eintreffen: API-Antworten, Ergebnisse von JSON.parse, webhook-Payloads.

Hier gibt der fetch-Hilfsfunktion absichtlich Promise<unknown> zurück. Das Direktlesen von .name führt zu einem Kompilierfehler, und es ist nur innerhalb eines Type Guards zulässig:

async function fetchUser(id: string): Promise<unknown> {
  const res = await fetch(`/api/user/${id}`);
  return res.json();
}

const raw = await fetchUser('123');
raw.name; // compile error, unknown has no properties
if (isUser(raw)) {
  raw.name; // fine, narrowed to User inside the guard
}

isUser ist ein vom Benutzer definiertes Type-Guard mit der Signatur function isUser(x: unknown): x is User, und sein Körper muss zur Laufzeit tatsächlich die Struktur überprüfen. Mit unknown weigert sich der Compiler, auf Eigenschaften zuzugreifen, solange die Daten nicht überprüft wurden. Bei any ist die Validierung optional, und solche optionalen Überprüfungen werden in der Regel übersprungen, bis eine fehlerhafte Antwort ein Komponente erreicht, das davon ausging, dass ein Feld immer vorhanden ist. Für größere Datenmengen kann eine Schema-Bibliothek wie Zod sowohl die Laufzeitüberprüfung als auch den Typ erzeugen. Weitere Muster zu diesem Thema finden Sie in sechs typsichere Ersatzlösungen für any.

Typen aus Code ableiten anstelle von Kopien zu pflegen

Falls eine Funktion bereits einen Wert erzeugt, benötigen Sie keine separate, manuell erstellte Schnittstelle für ihr Ergebnis. TypeScript kann diesen Wert automatisch extrahieren:

function createOrderPayload(order: Order) {
  return {
    id: order.id,
    total: order.total,
    lineItems: order.items.map(i => ({ sku: i.sku, qty: i.qty })),
  };
}

type OrderPayload = ReturnType<typeof createOrderPayload>;

ReturnType<typeof createOrderPayload> liest den aus dem Funktionsverhalten abgeleiteten Rückgabetyp. Ändern Sie die Funktion, und OrderPayload passt sich überall dort an, wo er verwendet wird. Im Gegensatz dazu entwickelt eine manuell gepflegte Schnittstelle Abweichungen: Nach einigen Anpassungen behauptet sie, ein Feld existiere, das die Funktion bereits seit längerer Zeit nicht mehr zurückgibt, und der Compiler vertraut weiterhin auf diese veraltete Schnittstelle.

Dieselbe Idee gilt auch für Komponenten aus Bibliotheken, die nicht Ihnen gehören. React.ComponentProps<typeof Button> liefert genau die Props, die eine von Dritten bereitgestellte Button-Komponente akzeptiert, und zwar aus deren eigenen Typdefinitionen – es gibt also keine „Schattenkopie“, die aktualisiert werden müsste, wenn die Bibliothek neue Props hinzufügt. Ähnliche Hilfsfunktionen wie Parameters, Awaited sowie indizierte Zugriffstypen (OrderPayload['lineItems'][number]) erweitern diesen Ansatz; für einen Überblick siehe TypeScripts eingebaute Hilfstypen.

Markenprimitivtypen, damit IDs nicht ausgetauscht werden können

Standardmäßig sind sowohl UserId als auch OrderId einfache Zeichenketten, wodurch das Übergeben einer Bestell-ID an eine Funktion, die einen Benutzer-ID erwartet, problemlos kompiliert. Die Abfrage gibt dabei einfach nichts zurück, und jemand muss Zeit investieren, um herauszufinden, warum.

Die Markenbildung verbindet das Primitive mit einer phantomhaften Eigenschaft, wodurch die beiden unvereinbar werden:

type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };

function getUser(id: UserId) { /* ... */ }
const orderId = 'ord_123' as OrderId;
getUser(orderId); // compile error: OrderId is not assignable to UserId

Die Eigenschaft __brand existiert niemals zur Laufzeit; sie befindet sich ausschließlich im Typsystem, was ausreicht, damit TypeScript die beiden Typen als unterschiedlich behandelt. Werte werden durch einen kleinen Konstruktor oder eine Validierungsfunktion erstellt (der as OrderId-Cast im Beispiel steht dafür), und ab diesem Zeitpunkt führt jede versehentliche Vermischung zu einem Kompilierfehler ohne jeglichen Laufzeitaufwand. Behalten Sie die Casts innerhalb dieser wenigen Konstruktoren, damit „Brand“ tatsächlich „dieser Wert wurde überprüft“ bedeutet.

Zusammenfassung

Dafür ist kein neues Framework oder eine neue Bibliothek erforderlich; es handelt sich dabei um den bereits vorhandenen TypeScript, der bewusst eingesetzt wird:

  • satisfies validiert, ohne die abgeleitete Struktur zu verwerfen; fügen Sie as const hinzu, wenn Literale wichtig sind.
  • Tagged Unions machen unmögliche Zustände undarstellbar.
  • Eine never-Prüfung verwandelt einen vergessenen Fall in einen Build-Fehler.
  • unknown an den Grenzen erzwingt eine Validierung vor der Verwendung.
  • Ableitungsarten bleiben im Einklang mit dem Code, der die Werte erzeugt.
  • Brands halten semantisch unterschiedliche Zeichenketten voneinander getrennt.
  • Wählen Sie eine Technik aus und wenden Sie sie in Ihrem nächsten Pull Request an. Jede davon verschiebt eine Kategorie von Fehlern vom Laufzeitbereich in den Kompilierzeitbereich – das ist der praktische Unterschied zwischen dem Installieren von TypeScript und dessen Schutzfunktion für Ihren Code.