Startseite / Artikel / Modellierung von Domänen in TypeScript: Jenseits grundlegender Typangaben

Modellierung von Domänen in TypeScript: Jenseits grundlegender Typangaben

Erlernen Sie praktische TypeScript-Gewohnheiten – von unknown vs any über differenzierte Unionen bis hin zu satisfies –, die Ihnen helfen, gültige Zustände abzubilden anstelle nur Daten zu kennzeichnen.

2330 Wörter

TypeScript ist überraschend einfach zu erlernen.

Zuerst lernt man Interfaces.

Dann Typ-Aliasse.

Anschließend kommen Unionen, Generika, Hilfstypen sowie gelegentlich auch mappierte Typen.

Bald schon kann man auf ein einfaches JavaScript-Objekt schauen und ohne Zögern einen Typ darauf anwenden.

Aber irgendwann geht es bei der Arbeit mit TypeScript nicht mehr darum, Typen an Dinge anzuhängen.

Es wird stattdessen darum, Typen absichtlich zu entwerfen.

Das ist eine grundlegend andere Fähigkeit, die man entwickeln muss.

Schauen Sie sich dieses Beispiel an:

type Payment = {
  status: 'SUCCESS' | 'FAILED'
  transactionId?: string
  error?: string
}

Auf den ersten Blick scheint es in Ordnung zu sein.

Aber überlegen Sie, welche Zustände dieser Typ technisch zulässt.

Er erlaubt alle folgenden:

{
  status: 'SUCCESS'
}

{
  status: 'SUCCESS',
  error: 'Something went wrong'
}

{
  status: 'FAILED',
  transactionId: '123'
}

{
  status: 'FAILED',
  error: 'Something went wrong'
}

Der Typ hat kein Konzept dafür, welche Kombinationen tatsächlich zusammen Sinn ergeben.

Das ist keine Einschränkung von TypeScript selbst.

Das ist ein Zeichen dafür, dass die Domäne schlecht modelliert wurde.

Eine bessere Version sieht so aus:

type Payment =
  | {
      status: 'SUCCESS'
      transactionId: string
    }
  | {
      status: 'FAILED'
      error: string
    }

Nun kodiert das Typsystem die eigentlichen Geschäftsregeln direkt.

Eine erfolgreiche Zahlung muss eine Transaktions-ID enthalten.

Eine fehlgeschlagene Zahlung muss eine Fehlermeldung enthalten.

Kombinationen, die keinen Sinn ergeben, werden schwierig oder sogar unmöglich zu erstellen.

Dort wird TypeScript wirklich nützlich.

Es geht nicht darum, überall mögliche Typangaben hinzuzufügen.

Sondern darum, dass Ihre Typen die Regeln ausdrücken, denen Ihre Anwendung tatsächlich folgt.

Unten finden Sie einige Gewohnheiten, die Sie in diese Richtung bringen.

1. Hören Sie auf, any zu verwenden, wenn Sie eigentlich „Ich weiß es nicht“ meinen

Eine der schnellsten Möglichkeiten, eine TypeScript-Kommentarmeldung zum Schweigen zu bringen, ist diese:

const response: any = await fetchData()

Mannchmal ist das tatsächlich der Fall.

Es tritt ein Fehler auf.

Man befindet sich mitten in der Implementierung.

Man kann sofort nicht den richtigen Typ ermitteln.

Deshalb wird any verwendet.

Der Compiler schweigt.

Aber auch alle Hilfen, die TypeScript Ihnen bot, verschwinden.

Sobald any in Ihre Codebasis eindringt:

const response: any = await fetchData()

response.user.profile.name // not checked
response.foo.bar.baz // not checked

TypeScript hat keine Möglichkeit, einen dieser Fehler aufzufangen.

Verwenden Sie unknown, wenn der Wert tatsächlich unbekannt ist

const response: unknown = await fetchData()

Das zwingt Sie dazu, vor der Verwendung tatsächlich herauszufinden, was der Wert ist.

if (typeof response === 'string') {
  console.log(response.toUpperCase())
}

Für alles Komplexere als Primitive sollten Sie stattdessen die Struktur an der Grenze überprüfen.

Der Unterschied ist hier wichtig:

unknown sagt "Ich weiß das noch nicht." any sagt "Ich möchte auf keinen Fall, dass TypeScript das überprüft."

Das sind zwei völlig unterschiedliche Absichten.

Wenn man mit Daten umgeht, die von außerhalb des Systems kommen, ist unknown fast immer der ehrlichere Ausgangspunkt.

2. Schreiben Sie nicht das, was TypeScript bereits weiß

Stark typisierten Code zu schreiben bedeutet nicht, jede einzelne Variable manuell anzugeben.

Diese Version:

const name: string = 'Akshat'
const age: number = 30
const active: boolean = true

ist nicht von Natur aus besser als diese hier:

const name = 'Akshat'
const age = 30
const active = true

TypeScript kann diese Typen bereits von selbst ableiten.

Jedes Element anzugeben, führt nur zu visuellem Überfluss, ohne echte Informationen hinzuzufügen.

Eindeutige Anmerkungen finden ihren Platz, wenn sie etwas Bedeutendes vermitteln.

Zum Beispiel:

function calculateTotal(
  items: Product[],
  discount: number
): number {
  // ...
}

Hier dokumentiert die Funktionssignatur im Grunde einen Teil eines Vertrags.

Das sind wirklich nützliche Informationen.

Eine hilfreiche Überprüfung ist:

Gibt diese Anmerkung TypeScript etwas mit, was es nicht bereits selbst herausfinden konnte?

Falls die Antwort nein lautet, können Sie sie wahrscheinlich weglassen.

3. Verwenden Sie as const, wenn Werte auch Typen sind

Betrachten Sie ein Objekt wie dieses:

const STATUS = {
  ACTIVE: 'ACTIVE',
  INACTIVE: 'INACTIVE',
}

Manchmal möchten Sie, dass die einzelnen Werte als literale Typen erhalten bleiben und nicht auf string erweitert werden.

D genau das bietet as const:

const STATUS = {
  ACTIVE: 'ACTIVE',
  INACTIVE: 'INACTIVE',
} as const

Von dort aus:

type Status = typeof STATUS[keyof typeof STATUS]

ergibt sich:

'ACTIVE' | 'INACTIVE'

Dieses Muster ist nützlich, wenn Sie sowohl die Laufzeitwerte als auch den entsprechenden Typ zur Kompilierzeit aus einer einzigen Definition benötigen.

Zum Beispiel:

export const ALTERNATE_CODE_TYPES = {
  CHARGE_CODE: 'CHARGE_CODE',
  NFTP_MDG_CODE: 'NFTP_MDG_CODE',
  FACT_MDG_CODE: 'FACT_MDG_CODE',
  CW1_CHARGE_CODE: 'CW1_CHARGE_CODE',
} as const

export type AlternateCodeType =
  typeof ALTERNATE_CODE_TYPES[keyof typeof ALTERNATE_CODE_TYPES]

Hier haben das Objekt selbst und der abgeleitete Typ denselben Ursprung.

Dadurch müssen Sie keine separate Deklaration wie folgt aufrechterhalten:

type AlternateCodeType =
  | 'CHARGE_CODE'
  | 'NFTP_MDG_CODE'
  | 'FACT_MDG_CODE'
  | 'CW1_CHARGE_CODE'

oben drauf.

Die Aufrechterhaltung einer einzigen Quelle der Wahrheit ist weitaus einfacher zu verwalten als das Manuell-Synchronisieren von zwei Definitionen.

4. Verwenden Sie Union-Typen, wenn das Domänenmodell eine feste Anzahl an Zuständen hat

Wenn ein Wert nur eine begrenzte Anzahl an möglichen Werten annehmen kann, sollten Ihre Typen das direkt angeben.

Anstatt Folgendes zu schreiben:

function setStatus(status: string) {
  // ...
}

vorzuziehen:

type Status = 'pending' | 'approved' | 'rejected'

function setStatus(status: Status) {
  // ...
}

Mit dieser Struktur funktioniert der folgende Aufruf einwandfrei:

setStatus('approved')

aber dieser wird abgelehnt:

setStatus('something-else')

Höher die Genauigkeit eines Typs, desto mehr kann der Compiler für Sie erledigen.

Der Vorteil geht weit über die Autocomplete-Funktionen des Editors hinaus. Eine präzise Union hilft außerdem bei:

  • Refactoring
  • Dokumentation
  • Fehlererkennung
  • API-Design
  • Auffindbarkeit

Falls Ihre Geschäftslogik tatsächlich nur drei mögliche Werte zulässt, sollten Sie dieses Feld nicht als lose Zeichenkette darstellen.

5. Greifen Sie nicht automatisch zu Enums

Enums sind manchmal das richtige Werkzeug, sollten aber nicht Ihre Standardwahl für jede Gruppe von Konstanten sein.

Falls Sie lediglich eine Union zur Laufzeitkompileierung benötigen, reicht oft Folgendes aus:

type Status = 'ACTIVE' | 'INACTIVE'

ist in den meisten Fällen ausreichend.

Falls Sie auch möchten, dass diese Werte zur Laufzeit vorhanden sind, verwenden Sie:

const STATUS = {
  ACTIVE: 'ACTIVE',
  INACTIVE: 'INACTIVE',
} as const

type Status = typeof STATUS[keyof typeof STATUS]

Dadurch haben Sie sowohl einen Typ als auch ein echtes Objekt zur Verfügung.

Der wichtigste Punkt, den man sich merken muss, ist, dass TypeScript-Typen nach dem Ausführen des Codes verschwinden. Einfache Objekte hingegen nicht.

Daher lautet die zu stellende Frage:

Muss dieser Wert zur Laufzeit existieren, oder dient er nur dazu, Dinge zur Kompilierzeit einzuschränken?

Wählen Sie den Ansatz, der zur Antwort passt.

6. Unzulässige Zustände undarstellbar machen

Das könnte die wertvollste Idee in der gesamten Diskussion sein.

Stellen Sie sich ein Formularkomponente vor, die in einem dieser Zustände sein kann:

  • loading
  • ready
  • submitting
  • successful
  • failed

Eine typische, aber fehlerhafte Art, dies zu modellieren, ist:

type FormState = {
  loading: boolean
  submitting: boolean
  error?: string
  data?: FormData
}

Mit dieser Struktur hindert Sie nichts daran, versehentlich etwas wie Folgendes zu erzeugen:

{
  loading: true,
  submitting: true,
  data: {...},
  error: 'Something went wrong'
}

Was repräsentiert diese Kombination eigentlich? Das Typsystem hat keine Ahnung, und auch der nächste Entwickler, der sie liest, wird es nicht wissen.

Eine bessere Struktur verbindet die Felder anhand des Zustands miteinander:

type FormState =
  | { status: 'loading' }
  | { status: 'ready'; data: FormData }
  | { status: 'submitting'; data: FormData }
  | { status: 'success'; data: FormData }
  | { status: 'error'; error: string }

Nun enthält jeder Zweig genau die Daten, die für ihn sinnvoll sind.

function render(state: FormState) {
  switch (state.status) {
    case 'loading':
      return 'Loading...'
    case 'ready':
      return state.data
    case 'submitting':
      return 'Submitting...'
    case 'success':
      return state.data
    case 'error':
      return state.error
  }
}

Das ist der Vorteil von diskriminierten Unionen. Anstatt eine Anwendung als einen unstrukturierten Haufen unabhängiger Boolescher Werte und optioneller Felder zu modellieren, modelliert man sie als eine feste Menge von legitimen Zuständen. Das ist eine weitaus zuverlässigere Grundlage.

7. Seien Sie vorsichtig mit optionalen Eigenschaften

Optionale Felder haben ihre Verwendungszwecke, doch sie sind auch ein einfacher Weg, um unbemerkt Unsicherheiten einzuschleusen.

Nehmen Sie dieses Beispiel:

type User = {
  id?: string
  name?: string
  email?: string
}

Durch diese Definition muss jeder Codeabschnitt, der einen User verarbeitet, nun den Fall handhaben, in dem keines dieser Felder vorhanden ist.

Aber vielleicht lautet die eigentliche Regel im Kontext:

Ein User hat immer eine ID, einen Namen und eine E-Mail-Adresse.

Falls das zutrifft, sollte das Modell entsprechend gestaltet werden:

type User = {
  id: string
  name: string
  email: string
}

Optionale Eigenschaften sollten Felder widerspiegeln, die tatsächlich manchmal fehlen. Sie dienen nicht dazu, eine vage Annahme darzustellen, dass der Entwickler des Typs nicht wusste, was die API tatsächlich zurücksenden würde.

Falls die Unsicherheit von einem externen System ausgeht, sollte sie direkt an dieser Grenze behandelt werden. Lassen Sie nicht zu, dass Unsicherheiten aus einer Integration sich im gesamten Codebase ausbreiten.

8. Verständnis von null vs undefined

In der Praxis spielt dieser Unterschied eine größere Rolle, als die Leute erwarten.

Nehmen wir diesen Typ:

type User = {
  middleName: string | null
}

Diese Formulierung deutet darauf hin:

Das Feld existiert, es wird aber absichtlich kein Wert für es angegeben.

Vergleichen wir das nun mit:

type User = {
  middleName?: string
}

was in der Regel bedeutet:

Das Feld könnte überhaupt nicht vorhanden sein.

Der Unterschied wird insbesondere bei APIs relevant. In einer PATCH-Anfrage kann dieser Body bedeuten:

{
  middleName: null
}

„Entfernen Sie den vorhandenen Vornamen.“

während dieser Body bedeuten kann:

{}

„Lassen Sie den Vornamen unverändert.“

Falls die Typen diesen Unterschied nicht ausdrücken können, können feine Fehler bereits auf der API-Ebene entstehen.

Vergessen Sie nicht, dass Typen dazu da sind, Bedeutungen zu vermitteln – und nicht nur den Compiler zufriedenzustellen.

9. Verwenden Sie satisfies anstelle blinder Typbehauptungen

Betrachten Sie einen Konfigurationstyp wie folgt:

type Config = {
  timeout: number
  retries: number
}

Eine Möglichkeit besteht darin, Folgendes zu schreiben:

const config = {
  timeout: 5000,
  retries: 3,
} as Config

Aber as ist eine Behauptung, und durch seine Verwendung sagt man im Grunde dem Compiler, den Wert ohne weitere Prüfung zu akzeptieren.

Ein allgemein besseres Vorgehen ist:

const config = {
  timeout: 5000,
  retries: 3,
} satisfies Config

Mit dieser Version überprüft TypeScript tatsächlich, ob das Objekt mit Config übereinstimmt, behält dabei aber weiterhin den engeren, aus dem Literal-Objekt abgeleiteten Typ bei.

Eine einfache Möglichkeit, den Unterschied zu merken:

as

Betrachten Sie diesen Wert so, als wäre er dieses Typs.

satisfies

Stellen Sie sicher, dass dieser Wert den Anforderungen dieses Typs entspricht.

Deshalb ist satisfies besonders nützlich für Konfigurationsobjekte, statische Zuordnungen und Suchtabellen.

10. Betrachten Sie as als Grenze, nicht als Standardwerkzeug

Es gibt Situationen, in denen eine Typbehauptung tatsächlich erforderlich ist. Doch das Schreiben von Folgendem:

const user = response as User

überprüft in Wirklichkeit nichts zur Laufzeit.

Nehmen wir an, ein API-Aufruf gibt tatsächlich zurück:

{
  username: 'akshat'
}

TypeScript hat keine Möglichkeit, diese Unstimmigkeit zu erkennen, denn die Behauptung hat bereits angegeben, den Wert unverändert anzunehmen. Hier wird dem Compiler nichts nachgewiesen – es wird einfach gebeten, wegzuschauen.

Das wird riskant dort, wo Daten von außerhalb des Codebases in diesen gelangen, wie zum Beispiel:

  • API-Antworten
  • localStorage
  • URL-Parameter
  • Umgebungsvariablen
  • Benutzereingaben
  • Drittanbieter-Bibliotheken

Sobald Daten in eine Anwendung von einer Quelle eintreffen, die TypeScript nicht einsehen kann, lohnt es sich, diese Daten zu validieren anstatt sie zu casten. Eine Laufzeit-Schema-Prüfung kann tatsächlich bestätigen, dass:

"Diese Daten entsprechen tatsächlich der Struktur, die die Anwendung erwartet."

Das ist eine weitaus stärkere Garantie als einfach nur Folgendes zu schreiben:

value as User

TypeScript ist ein Tool zur Kompilierzeit – und zwar eines sehr guten. Es wurde niemals dafür entwickelt, zu überprüfen, was während des Laufs eines Programms geschieht.

Das eigentliche Ziel: Das Domänenmodell erstellen

Sobald diese Denkweise verinnerlicht ist, wirkt TypeScript nicht mehr wie ein Syntax-Übung. Anstatt zu fragen „Wie tippe ich dieses Objekt ein?“, stellt man sich die Frage „In welchen Zuständen kann dieses Objekt eigentlich sein?“ Anstatt zu fragen „Sollte diese Eigenschaft optional sein?“, fragt man sich stattdessen „Ist diese Eigenschaft wirklich optional, oder verbirgt sie nur etwas, das noch nicht bekannt ist?“ Anstatt zu fragen „Kann as hier verwendet werden?“, geht es darum zu prüfen „Kann dieser Wert tatsächlich als vom behaupteten Typ zu betrachten sein?“

Diese Veränderung ist der eigentliche Sinn. Gutes TypeScript schreiben bedeutet nicht, mehr Typangaben hinzuzufügen – es geht darum, dass die von einem geschriebenen Typen tatsächlich eine Bedeutung haben.

Eine einfache Regel zum Merken

Jedes Mal, wenn man einen Typ entwirft, sollte man ihn drei Fragen unterziehen:

1. Welche Zustände sind tatsächlich zulässig?

Falls ein Typ die Darstellung ungültiger Zustände zulässt, muss das Modell selbst wahrscheinlich neu überdacht werden.

2. Was weiß der Compiler bereits?

Vermeiden Sie es, aus reiner Gewohnheit Annotierungen hinzuzufügen – lassen Sie die Inferenz die Arbeit erledigen, die sie bereits leisten kann.

3. Wo wird diese Datenquelle zuverlässig?

Je weiter die Daten von ihrer ursprünglichen externen Quelle entfernt sind, desto mehr Sicherheit sollten ihre Typen ausdrücken dürfen.

Ein starkes TypeScript wird nicht dadurch definiert, wie ausgefeilt seine Typen sind. Es wird durch Typen definiert, die eine korrekte Implementierung offensichtlich machen und eine falsche Implementierung schwer zu schreiben erschweren.

Sobald die Typen mit dieser Denkweise entworfen werden, wirkt TypeScript nicht mehr wie eine auf JavaScript aufgepfropfte Schicht. Es wird zu einem Bestandteil der tatsächlichen Erstellung einer Anwendung.

Zusätzliche Literatur

  • Master TypeScripts eingebaute Utility Types für saubereren Code — Erfahren Sie, wie TypeScripts Utility Types wie Partial, Pick, Omit und Record doppelte Schnittstellen beseitigen und Typdefinitionen automatisch synchron halten.