Startseite / Artikel / TypeScript-Behavioren, die erfahrene Entwickler überraschen – und warum

TypeScript-Behavioren, die erfahrene Entwickler überraschen – und warum

Strukturelle Typisierung, Überprüfungen von Eigenschaften, as const, satisfies, bedingte und mappierte Typen sowie die Entwurfsprinzipien, die sie in sichereren Code verwandeln.

4295 Wörter

Die meisten Entwickler beginnen damit, TypeScript als JavaScript mit Anmerkungen zu betrachten, und stoßen anschließend auf Verhaltensweisen, die nicht zu diesem Bild passen: Ein Objekt mit zusätzlichen Feldern wird an einem Ort akzeptiert und an einem anderen abgelehnt, eine Typbehauptung „konvertiert“ nichts, und readonly ermöglicht weiterhin Änderungen an eingebetteten Werten. Unter den grundlegenden Anmerkungen verbirgt sich eine sprachliche Struktur auf Typebene mit eigenen Regeln für Kompatibilität, Inferenz und Berechnung. Diese Anleitung erläutert diese Überraschungen nacheinander – von der Laufzeitlöschung und dem strukturellen Typen bis hin zu infer, Template-Literal-Typen und satisfies – und wandelt sie anschließend in praktische Entwurfsprinzipien um, die Sie auf eine reale Codebasis anwenden können.

Typen existieren nicht mehr, sobald der Code ausgeführt wird

Hier ist eine Schnittstelle sowie ein damit annotiertes Objekt:

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

const user: User = {
  id: 1,
  name: "Lakhveer"
};

Es ist verlockend zu glauben, das laufende Programm wüsste, dass user ein User ist. Das ist nicht der Fall. Die Kompilierung entfernt die Schnittstelle, und was übrig bleibt, ist im Grunde Folgendes:

const user = {
  id: 1,
  name: "Lakhveer"
};

Während der Laufzeit existiert kein User-Wert. TypeScript führt zunächst eine statische Analyse durch, gibt anschließend JavaScript aus, und nur dieses JavaScript erreicht den Engine:

TypeScript
    ↓
Type Checking
    ↓
JavaScript Generation
    ↓
Browser / Node.js

Typen informieren den Compiler; sie sind keine Laufzeitobjekte. Die praktische Folge ist, dass TypeScript niemals Daten überprüft, die von außerhalb des Programms kommen. Die untenstehende Anmerkung besagt lediglich, was der Server zurückgibt; nichts prüft das:

const response: User = await fetch("/api/user")
  .then(res => res.json());

Für externe Daten ist eine Laufzeitüberprüfung notwendig. Eine Schema-Bibliothek wie Zod beschreibt die Struktur einmal und überprüft sie, wenn die Daten eintreffen:

const UserSchema = z.object({
  id: z.number(),
  name: z.string()
});

const user = UserSchema.parse(data);

Eine nützliche Art, sich an die Trennung zu erinnern: Der Compiler schützt Ihren Code, während die Laufzeitvalidierung Ihre Anwendung schützt. Der Leitfaden des Blogs zu der Verwendung derselben Zod-Schema in einem React-Frontend und einem Node-Backend zeigt, wie man dies an beiden Enden anwendet.

any, unknown und die Beweislast

any deaktiviert die Überprüfung

Mit any wird jede dieser sinnlosen Operationen ohne Probleme kompiliert:

let value: any = "hello";

value.foo.bar.baz();
value();
value.notARealProperty;

any sagt im Grunde dem Compiler, ihm zu vertrauen und nicht weiter nachzusehen. Deshalb wirft ein Parameter mit dieser Typisierung stillschweigend den größten Teil der Vorteile von TypeScript weg:

function processUser(user: any) {
  console.log(user.name);
}

unknown verlangt Beweise

unknown akzeptiert ebenfalls jeden Wert:

let value: unknown = "hello";

Aber die direkte Verwendung scheitert:

value.foo;

Zuerst muss man es eingrenzen, zum Beispiel auf einen String:

if (typeof value === "string") {
  console.log(value.toUpperCase());
}

oder auf eine Zahl:

if (typeof value === "number") {
  console.log(value.toFixed(2));
}

Der Unterschied in der Einstellung lässt sich leicht zusammenfassen:

any
 ↓
"Trust me"

unknown
 ↓
"Prove it first"

Wenn man also tatsächlich den Typ eines Wertes nicht kennt, sollte man

unknown

anstatt dessen

any

verwenden und den Compiler dazu bringen, zu überprüfen, was man vor der Verwendung besitzt.

Kompatibilität geht um die Struktur, nicht um Namen

Entwickler aus Java, C# oder C++ sind oft überrascht, dass diese Zuweisung zulässig ist:

interface User {
  name: string;
}

const employee = {
  name: "Lakhveer",
  salary: 100000
};

const user: User = employee;

TypeScript verwendet strukturelles Typisieren: Die Kompatibilität hängt davon ab, welche Eigenschaften ein Wert hat, nicht davon, wie er deklariert wurde. User benötigt nur Folgendes:

name: string

und employee besitzt das, plus weitere Eigenschaften. Die Logik, die der Compiler anwendet, sieht so aus:

User requires:
    name: string

employee has:
    name: string
    salary: number

Therefore:
    employee satisfies User

oder als Entscheidungsfluss:

Required properties
        ↓
Does object contain them?
        ↓
Yes
        ↓
Compatible

Übermäßige Eigenschaftsprüfungen gelten für frische Literale

Nun der Clou: Das Direkt-Einfügen eines zusätzlichen Feldes in ein Objektliteral wird abgelehnt:

interface User {
  name: string;
}

const user: User = {
  name: "Lakhveer",
  salary: 100000
};

mit einem Fehler wie:

Object literal may only specify known properties

Doch die Zuweisung derselben Daten über eine Zwischenvariable funktioniert:

const employee = {
  name: "Lakhveer",
  salary: 100000
};

const user: User = employee;

Der Grund ist, dass TypeScript bei frischen Objektliteralen, also solchen, die direkt zum Zeitpunkt der Zuweisung geschrieben werden, übermäßige Eigenschaftsprüfungen durchführt, um Tippfehler zu verhindern. Diese Prüfung ist von der strukturellen Kompatibilität getrennt. Daher gilt „TypeScript lehnt zusätzliche Eigenschaften ab“ nur für Literale; sobald ein Objekt bereits über eine Variable weitergeleitet wurde, sind zusätzliche Felder in Ordnung.

Literaltypen und abgeleitete Unionen

Eindeutige Werte anstelle von allgemeinen Typen

Eine Variable kann auf bestimmte Werte beschränkt werden:

let direction: "left" | "right";
direction = "left";

Daher scheitert diese Zuweisung:

direction = "up";

Die Deklaration besagt nicht

direction: string

Sie besagt

direction must be EXACTLY:
"left"
OR
"right"

Dass Präzision APIs erheblich verbessert. Eine Bereitstellungsfunktion kann nur bekannte Umgebungen akzeptieren:

type Environment =
  | "development"
  | "staging"
  | "production";
function deploy(env: Environment) {
  // ...
}

und ein Tippfehler führt zu einem Kompilierfehler statt zu einer fehlgeschlagenen Bereitstellung:

deploy("testing");

as const verändert das, was abgeleitet wird

Ein gewöhnliches Array-Literal aus Zeichenketten:

const colors = ["red", "blue", "green"];

wird als

string[]

Durch Hinzufügen von as const:

const colors = ["red", "blue", "green"] as const;

wird stattdessen ein nur zum Lesen zugängliches Tupel von Literaltypen erzeugt:

readonly ["red", "blue", "green"]

Aus diesem Tupel kann man durch Indizierung mit number eine Union ableiten:

type Color = typeof colors[number];

was folgendes ergibt:

type Color = "red" | "blue" | "green";

Dies beseitigt eine häufige Duplikation. Ohne dieses Verfahren muss man eine Union sowie ein Array verwalten, die manuell synchron gehalten werden müssen:

type Color = "red" | "blue" | "green";

const colors: Color[] = [
  "red",
  "blue",
  "green"
];

Durch dieses Verfahren wird das Array zur einzigen Quelle der Wahrheit, und die Typisierung erfolgt automatisch:

const colors = [
  "red",
  "blue",
  "green"
] as const;

type Color = typeof colors[number];

Das Prinzip gilt allgemein: Wenn das Typsystem Informationen ableiten kann, sollte man sie nicht zweimal schreiben.

Schlüsselwörter, die auf Typebene funktionieren

typeof hat zwei Aufgaben

In JavaScript,

typeof value

ist ein Laufzeitoperator. Zum Beispiel,

typeof "hello";

ergibt folgendes Ergebnis

"string"

In einer typbezogenen Situation wiederverwendet TypeScript das Schlüsselwort, um den statischen Typ einer Variablen zu erfassen:

const user = {
  id: 1,
  name: "Lakhveer"
};

type User = typeof user;

Hier

User

wird zu

{
  id: number;
  name: string;
}

Das gleiche Schlüsselwort, zwei Kontexte:

Runtime:
typeof value

Type system:
typeof variable

keyof wandelt Schlüssel in eine Union um

Gegeben ist eine Schnittstelle,

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

diese Typ

type UserKeys = keyof User;

ist

"id" | "name" | "email"

In Kombination mit Generics lässt sich ein Eigenschaftszugriffsmethoden schreiben, der nur echte Schlüssel akzeptiert:

function getValue<T, K extends keyof T>(
  object: T,
  key: K
) {
  return object[key];
}

Der Aufruf mit einem vorhandenen Schlüssel funktioniert:

const user = {
  id: 1,
  name: "Lakhveer"
};

getValue(user, "name");

während ein fehlender Schlüssel zur Kompilierzeit abgelehnt wird:

getValue(user, "salary");

Auch der Rückgabetyp ist präzise: T[K] gibt den Typ dieser bestimmten Eigenschaft zurück.

Generics verbinden Werte miteinander

Der klassische Generic gibt einfach das zurück, was er erhält:

function identity<T>(value: T): T {
  return value;
}

Generics werden interessanter, wenn sie mehrere Werte miteinander verknüpfen. Hier müssen beide Argumente denselben Typ haben:

function pair<T>(first: T, second: T): [T, T] {
  return [first, second];
}

deshalb wird dieser Aufruf akzeptiert:

pair(10, 20);

Aber dieser Fall scheitert, weil T aus dem ersten Argument als number abgeleitet wird und eine Zeichenkette nicht darauf zugewiesen werden kann:

pair(10, "hello");

Generics können auch ehrlich Eingang mit Ausgang verbinden, einschließlich des leeren Falls:

function first<T>(items: T[]): T | undefined {
  return items[0];
}

Für einen Aufruf wie

const numbers = first([1, 2, 3]);

meldet der Compiler das Ergebnis als

number | undefined

Typberechnung

Bedingte Typen sind ein if auf Typebene

Ein bedingter Typ wählt zwischen zwei Ergebnissen auf der Grundlage einer Überprüfung aus:

type IsString<T> =
  T extends string
    ? true
    : false;

Daher

type A = IsString<string>;

löst sich in

true

und

type B = IsString<number>;

löst sich in

false

Konzeptionell schreiben Sie das hier, nur läuft es im Compiler und nicht in Ihrem Programm:

if T is string
    return true
else
    return false

infer extrahiert Teile eines Typs

In einem bedingten Typ führt infer eine Typvariable ein, die von TypeScript durch Abgleich gefüllt wird. Dadurch wird der eingebaute ReturnType neu implementiert:

type ReturnTypeOf<T> =
  T extends (...args: any[]) => infer R
    ? R
    : never;

Wenn er auf eine echte Funktion angewendet wird, extrahiert er den Typ des zurückgegebenen Objekts:

function getUser() {
  return {
    id: 1,
    name: "Lakhveer"
  };
}

type User = ReturnTypeOf<typeof getUser>;

Das mentale Modell entspricht einem Musterabgleich:

Function
   ↓
infer R
   ↓
Extract return type

Viele der Standard-Hilftypen werden genau auf diese Weise erstellt.

Kartierte Typen wandeln jede Eigenschaft um

Aus einer Schnittstelle ausgehend,

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

iteriert ein kartierter Typ über seine Schlüssel, um eine nur zum Lesen zugängliche Version zu erzeugen:

type ReadonlyUser = {
  readonly [K in keyof User]: User[K];
};

oder eine optionale Version:

type OptionalUser = {
  [K in keyof User]?: User[K];
};

anstatt jedes Feld manuell umzuschreiben:

id?: number;
name?: string;
email?: string;

Die Hilftypen werden aus diesen Bausteinen erstellt

TypeScript stellt eine Bibliothek solcher Hilfsmittel bereit:

Partial<T>
Required<T>
Readonly<T>
Pick<T, K>
Omit<T, K>
Record<K, T>
Exclude<T, U>
Extract<T, U>
NonNullable<T>
ReturnType<T>
Parameters<T>

Gegeben ein solches Modell,

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

Pick behält die ausgewählten Schlüssel bei:

type UserPreview = Pick<User, "id" | "name">;

erzeugt

{
  id: number;
  name: string;
}

und Omit entfernt sie:

type UserWithoutEmail = Omit<User, "email">;

Für die vollständige Übersicht siehe den Blog-Guide zu TypeScripts eingebauten Utility-Typen.

never, Narrowing und Guards

never beweist, dass alles behandelt wurde

never ist der Typ eines Werts, der nicht existieren kann – wie zum Beispiel das Ergebnis einer Funktion, die immer einen Fehler auslöst:

function fail(message: string): never {
  throw new Error(message);
}

Seine wahre Kraft zeigt sich bei Ausführlichkeitsprüfungen. Nehmen wir eine Status-Union:

type Status =
  | "loading"
  | "success"
  | "error";

und einen Switch, dessen default-Branch den Wert an eine Funktion weiterleitet, die nur never akzeptiert:

function handleStatus(status: Status) {
  switch (status) {
    case "loading":
      return "Loading";
    case "success":
      return "Success";
    case "error":
      return "Error";
    default:
      return assertNever(status);
  }
}

function assertNever(value: never): never {
  throw new Error("Unexpected value: " + value);
}

Sobald alle Fälle abgehandelt wurden, ist der status bis zum Erreichen von default bereits auf never eingegrenzt worden, weshalb eine Typüberprüfung durchgeführt wird. Nehmen wir nun an, jemand erweitert die Union:

type Status =
  | "loading"
  | "success"
  | "error"
  | "cancelled";

Der unverarbeitete Wert "cancelled" erreicht assertNever; er kann nicht auf never zugewiesen werden, und der Compiler weist auf alle Switch-Anweisungen hin, die aktualisiert werden müssen.

Eingrenzung folgt dem Kontrollfluss

Der Compiler verfolgt, wie Überprüfungen bestimmen, was eine Variable sein kann:

function print(value: string | number) {
  if (typeof value === "string") {
    console.log(value.toUpperCase());
  } else {
    console.log(value.toFixed(2));
  }
}

In der ersten Abzweigung,

value

ist bekannt, dass es

string

und in der zweiten ist es

number

Customisierte Typschützer

Man kann dem Compiler beibringen, eigene Typen mit einer Funktion zu erkennen, deren Rückgabetyp ein Typprädikat ist, value is User:

interface User {
  name: string;
}

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "name" in value
  );
}

Nachdem der Guard erfolgreich war,

const data: unknown = getData();

if (isUser(data)) {
  console.log(data.name);
}

behandelt der Compiler den Wert als

data: User

innerhalb des Blocks. Beachten Sie, dass der Compiler dem Prädikat vollständig vertraut. Dieser Guard überprüft lediglich, ob name existiert, nicht jedoch, dass es sich um einen String handelt; daher ist ein nachlässiger Guard im Grunde eine unüberprüfte Behauptung.

Modellierung von Zuständen mit diskriminierten Unionen

Ein gängiges, aber schwaches Design bringt alle Möglichkeiten in ein Objekt mit optionellen Feldern unter:

interface State {
  status: string;
  data?: User;
  error?: string;
}

Eine diskriminierte Union modelliert jeden Zustand separat, gekennzeichnet durch status:

type State =
  | {
      status: "loading";
    }
  | {
      status: "success";
      data: User;
    }
  | {
      status: "error";
      error: string;
    };

Durch Schalten auf das Tag werden jede Zweigstrecke auf genau die dort vorhandenen Felder eingegrenzt:

function render(state: State) {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      return state.data.name;
    case "error":
      return state.error;
  }
}

Dadurch werden Widersprüche wie

status = success
error = "Something went wrong"

was die lockere Version gerne zulässt. Das Leitprinzip besteht darin, gültige Zustände abzubilden anstatt ungültige zuzulassen und überall nach ihnen zu prüfen.

erfüllt Prüfungen ohne Überschreiben

satisfies überprüft, ob eine Expression zu einem Typ passt, wobei der eigene, abgeleitete Typ der Expression beibehalten wird:

const config = {
  port: 3000,
  host: "localhost"
} satisfies {
  port: number;
  host: string;
};

Das ist ideal für Konfigurationsobjekte, bei denen man zwar Prüfungen möchte, aber auch literale Werte sowie exakte Schlüssel beibehalten will. Vergleichen Sie eine Assertion:

const config = {...} as Config;

as weist den Compiler an, den Wert als diesen Typ zu behandeln, und er wird Ihrem Wort Glauben schenken. satisfies hingegen bittet den Compiler um Bestätigung der Konformität. Wenn es darum geht, zu validieren statt den Prüfer zu überschreiben, sollte man lieber

satisfies

verwenden

as

Beschränkungen, die zu Problemen führen

readonly ist oberflächlich

Betrachten Sie einen Typ mit nur zum Lesen zugänglichen Eigenschaften, von denen eine ein Objekt ist:

type User = {
  readonly name: string;
  readonly address: {
    city: string;
  };
};

Die Neuzuweisung der Eigenschaft auf höchster Ebene wird blockiert:

user.name = "New Name";

doch das Ändern eines Feldes innerhalb des verschachtelten Objekts ist weiterhin erlaubt:

user.address.city = "Indore";

readonly gilt nur für die von ihm markierte Eigenschaft und nicht rekursiv. Für tiefe Immutabilität ist ein rekursiver mappierter Typ oder ein Laufzeitmechanismus erforderlich; beachten Sie außerdem, dass auch Object.freeze oberflächlich ist.

Aussagen wandeln Werte nicht um

Diese doppelte Aussage kompiliert:

const value = "123" as unknown as number;

doch nichts wird umgewandelt. Bei Laufzeit,

typeof value

wird weiterhin berichtet

string

Falls Sie eine Zahl benötigen, wandeln Sie sie explizit um:

const value = Number("123");

Aussagen ändern nur, was der Compiler annimmt, niemals den tatsächlichen Wert.

Optional ist nicht immer dasselbe wie undefined

Eine optionale Eigenschaft:

interface User {
  name?: string;
}

bedeutet in der Regel, dass der Schlüssel fehlen kann, sodass ein leeres Objekt entsteht

{}

ist gültig, genauso wie

{
  name: "Lakhveer"
}

Aber ob ein expliziter Wert

{
  name: undefined
}

erlaubt ist, hängt von der Konfiguration ab. Die Aktivierung

{
  "exactOptionalPropertyTypes": true
}

ermöglicht es dem Compiler, die beiden zu unterscheiden. Das ist wichtig für APIs, bei denen

property missing

und

property explicitly undefined

verschiedene Bedeutungen haben, zum Beispiel in einer PATCH-Anfrage, bei der ein fehlendes Feld „unverändert lassen“ bedeutet und ein expliziter Wert „löschen“.

Indexierung kann undefined verbergen

TypeScript versucht absichtlich nicht, jeden Laufzeitfehler zu verhindern. Das Lesen über das Ende eines Arrays:

const numbers = [1, 2, 3];
const value = numbers[100];

wird bei Standardeinstellungen so typisiert, als gäbe es immer eine Zahl. Die Aktivierung von

{
  "noUncheckedIndexedAccess": true
}

ermöglicht den Zugriff

numbers[100]

meldet als

number | undefined

was einen dazu zwingt, den fehlenden Fall zu handhaben.

String- und relationale Typen

Template-Literal-Typen

TypeScript kann String-Typen aus anderen String-Typen erstellen:

type EventName =
  `user:${"created" | "updated" | "deleted"}`;

was sich auf

"user:created"
"user:updated"
"user:deleted"

Die gleiche Technik kann auch Routen beschreiben:

type HttpMethod = "GET" | "POST";

type Endpoint =
  `${HttpMethod} /users`;

damit die gültigen Werte sind

"GET /users"
"POST /users"

Verknüpfung der Bestandteile zu berechneten APIs

Diese Funktionen bilden zusammen:

keyof
typeof
conditional types
mapped types
template literals
infer
generics

Beginnen Sie mit einer Karte von Ereignisnamen zu Payload-Typen:

type EventMap = {
  userCreated: {
    id: number;
  };

userDeleted: {
    id: number;
  };
};

Ein generischer Emittent kann anschließend jeden Ereignisnamen mithilfe von keyof und indiziertem Zugriff mit seinem Payload verknüpfen:

class EventEmitter<Events extends Record<string, unknown>> {
  on<K extends keyof Events>(
    event: K,
    callback: (payload: Events[K]) => void
  ) {
    // ...
  }

emit<K extends keyof Events>(
    event: K,
    payload: Events[K]
  ) {
    // ...
  }
}

Das Senden eines bekannten Ereignisses mit dem richtigen Payload kompiliert erfolgreich:

const emitter =
  new EventEmitter<EventMap>();

emitter.emit("userCreated", {
  id: 1
});

während ein falscher Payload abgelehnt wird:

emitter.emit("userCreated", {
  name: "Lakhveer"
});

Der Compiler versteht nun die Beziehung zwischen dem Namen eines Events und den Daten, die es begleiten müssen.

Straff starten

Ein professionelles Projekt sollte im Allgemeinen mit folgendem beginnen:

{
  "compilerOptions": {
    "strict": true
  }
}

Dieses einzige Flag aktiviert eine Reihe von Überprüfungen, darunter:

strictNullChecks
noImplicitAny
strictFunctionTypes
strictPropertyInitialization
useUnknownInCatchVariables

Es schaltet außerdem Optionen wie strictBindCallApply und noImplicitThis ein. Sicherheitsmaßnahmen erst nach dem Auftreten von Fehlern einzuführen, ist weitaus teurer, als den Compiler als erste Verteidigungslinie zu nutzen.

Ungültige Zustände undarstellbar machen

Betrachten Sie einen Anfrage-Lebenszyklus, der als Union modelliert wird:

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string };

Vergleichen Sie dies mit einem Design aus Flaggen und optionalen Feldern:

interface RequestState {
  loading: boolean;
  data?: User;
  error?: string;
}

Das zweite Design erlaubt Unsinn wie

{
  loading: true,
  data: user,
  error: "Something failed"
}

während das Erste es unmöglich macht, solche Kombinationen zu erstellen. Wenn es ein Designprinzip gibt, das man aus TypeScript übernehmen sollte, dann ist es dieses. Für eine ausführlichere Behandlung siehe Modellierung von Domänen in TypeScript jenseits grundlegender Typangaben.

Designprinzipien für produktives TypeScript

Die Kenntnis der Funktionen bedeutet nicht automatisch, dass man damit gut designen kann. Die folgenden Prinzipien beziehen sich darauf, wie man Systeme gestaltet.

Betrachte alles als letztes Mittel

Anstatt

function process(data: any) {
  // ...
}

wählt man lieber

function process(data: unknown) {
  // validate/narrow first
}

und, wenn man die Struktur kennt, umso besser

function process(data: User) {
  // ...
}

Verwende any nur dann, wenn du genau weißt, was du aufgibst.

Lass die Inferenz das Offensichtliche übernehmen

Anmerkungen wie diese fügen nur Störgeräusche hinzu:

const name: string = "Lakhveer";
const age: number = 28;

Der Compiler weiß bereits:

const name = "Lakhveer";
const age = 28;

Speichern Sie explizite Typen dort auf, wo sie einen Vertrag dokumentieren.

Lassen Sie die Typen die Absicht widerspiegeln

Eine einfache Zeichenkette sagt wenig aus:

function process(value: string) {}

Ein benannter Typ macht klar, was der Wert bedeutet:

type UserId = string;
function processUser(userId: UserId) {}

Eine Einschränkung: Ein Alias wie UserId = string dokumentiert die Absicht, verhindert aber nicht, dass Sie einen ProductId dort übergeben, wo ein UserId erwartet wird – schließlich sind beide nur Zeichenketten. Wenn ein Verwechslungsrisiko besteht, sorgt ein markierter Typ für diese Einhaltung.

Lassen Sie die Typen nah am Anwendungsbereich

Eine Signatur, die aus rohen Zeichenketten besteht:

function createOrder(
  userId: string,
  productId: string,
  status: string
) {}

wird mit Domänen-Typen viel klarer:

type OrderStatus =
  | "pending"
  | "paid"
  | "cancelled";

function createOrder(
  userId: UserId,
  productId: ProductId,
  status: OrderStatus
) {}

Nun versteht der Compiler Ihr Geschäfts-Vokabular, nicht nur primitive Datentypen.

Verwenden Sie Unionen statt boolescher Flags

Unabhängige Boolesche Werte ermöglichen unmögliche Kombinationen:

interface State {
  loading: boolean;
  success: boolean;
  error: boolean;
}

Eine Union erlaubt jeweils genau einen Zustand zur gleichen Zeit:

type State =
  | "loading"
  | "success"
  | "error";

Wechseln Sie zu einer diskriminierten Union, wenn ein Zustand eigene Daten benötigt.

Validieren Sie an den Systemgrenzen

Der Compiler kann für nichts garantieren, was von außen eingeht:

API
Database
User input
Environment variables
Files
Third-party services
JSON
Local storage

Betrachten Sie all diese Eingänge als unzuverlässig und leiten Sie sie über einen einzigen Pipeline-Prozess weiter:

External data
     ↓
Runtime validation
     ↓
Trusted typed data
     ↓
Application logic

Validierte Daten werden zu vertrauenswürdigen, typisierten Daten, und nur diese erreichen die Anwendungslogik.

Wehren Sie sich gegen Überengineering

Man kann äußerst komplexe Typen schreiben, aber eine Signatur wie

type Something<T, U, V, X extends ...> = ...

die nach sechs Monaten niemand im Team mehr erklären kann, stellt technischen Schulden dar. Typen sollten die Codebasis klarer machen, nicht Cleverness demonstrieren.

Entwerfen Sie APIs für die richtige Nutzung

Eine Aufrufstruktur mit mehreren Positionalflaggen lässt sich leicht falsch verstehen:

createUser(
  "Lakhveer",
  "admin",
  true,
  false,
  undefined
);

Ein typisiertes Options-Objekt ist selbstbeschreibend und bietet eine weitaus bessere Autocomplete-Funktion:

createUser({
  name: "Lakhveer",
  role: "admin",
  active: true
});

Komponieren statt riesige Schnittstellen zu erstellen

Eine einzige Schnittstelle mit Dutzenden von Feldern

interface User {
  // 50 properties
}

ist schwieriger zu verstehen als kleinere Konzepte in Kombination mit Schnittmenge-Operationen:

type Identifiable = {
  id: string;
};

type Timestamped = {
  createdAt: Date;
  updatedAt: Date;
};

type User =
  Identifiable &
  Timestamped & {
    name: string;
  };

Den Compiler in Ihre Teststrategie einbeziehen

Typen ersetzen Tests nicht, aber sie beseitigen ganze Kategorien von Fehlern bereits vor dem Ausführen der Tests. Wenn

type PaymentStatus =
  | "pending"
  | "paid"
  | "failed";

ein neues Mitglied hinzugefügt wird, wie zum Beispiel

"refunded"

wird dies in Kombination mit Ausführlichkeitsprüfungen jeden Ort aufzeigen, an dem es bisher nicht behandelt wurde.

Trennen Sie im Kopf Kompilierzeit und Laufzeit voneinander

Fragen Sie sich, in welcher Schicht Sie arbeiten. Dies betrifft ausschließlich die Kompilierzeit:

interface User {
  id: number;
}

Das ist eine Laufzeitprüfung:

if (typeof value === "object") {
}

Und das ist die Laufzeitvalidierung externer Daten:

UserSchema.parse(data);

Lesen Sie den generierten JavaScript-Code

Wenn das Verhalten verwirrend ist, fragen Sie nach dem JavaScript, in das der Code umgewandelt wird. Das Verständnis beider Ebenen erklärt die meisten Überraschungen.

Kennen Sie JavaScript gründlich

TypeScript basiert auf JavaScript, daher bleiben die Grundlagen weiterhin wichtig:

Closures
Promises
Event Loop
Prototypes
this
Modules
Destructuring
Async/Await
Objects
Arrays
Functions
Hoisting
Scopes

Halten Sie tsconfig.json bewusst gestaltet

Kopieren Sie eine Konfiguration nicht blind. Verstehen Sie, was jede Option bringt und was sie kostet:

{
  "strict": true,
  "noUncheckedIndexedAccess": true,
  "exactOptionalPropertyTypes": true,
  "noImplicitOverride": true
}

Jede Einstellung verschiebt das Gleichgewicht zwischen Sicherheit und Bequemlichkeit; noImplicitOverride erfordert beispielsweise eine override-Anweisung für jede Methode, die eine Methode aus einer Basisklasse ersetzt.

Ein schichtweises mentales Modell

Es hilft, TypeScript als zwei parallele Schichten zu betrachten – JavaScript zur Laufzeit und ein Typsystem zur Kompilierzeit – wobei die typbezogenen Funktionen ineinander aufbauen:

                TypeScript
                     │
        ┌────────────┴────────────┐
        │                         │
   JavaScript                 Type System
        │                         │
 Runtime Behavior          Compile-Time Safety
        │                         │
 Browser / Node            Type Relationships
                                  │
                       ┌──────────┼──────────┐
                       │          │          │
                    Generics    Unions    Inference
                       │          │          │
                    keyof      never     conditional
                       │          │          │
                    mapped     guards      infer
                       │          │          │
                       └──────────┴──────────┘

So betrachtet wirkt TypeScript nicht mehr wie eine Ansammlung von Syntaxregeln, sondern wird zu einer Sprache zur Beschreibung von Beziehungen zwischen Werten: Welche Werte sind zulässig, wie Objekte miteinander verbunden sind, welche Zustände auftreten können, welche Funktionen Eingaben entgegennehmen und Ausgaben liefern sowie welche Fälle noch unberücksichtigt sind.

Haupterkenntnisse

Die Funktionen, die am meisten beherrscht werden sollten, sind nicht unbedingt die auffälligsten:

Generics
Unions
Narrowing
Inference
keyof
typeof
Mapped Types
Conditional Types
infer
Discriminated Unions
never
unknown
satisfies
Template Literal Types
  • Typen werden zur Laufzeit gelöscht, weshalb externe Daten stets validiert werden müssen.
  • Die Kompatibilität ist strukturell geprägt, wobei nur bei neuen Objektliteralen eine zusätzliche Überprüfung erfolgt.
  • as const, typeof, keyof, bedingte und mappierte Typen ermöglichen es Ihnen, Typen abzuleiten anstelle sie zu duplizieren.
  • Verwenden Sie lieber unknown anstelle von any und satisfies anstelle von as, wenn Ihr Ziel die Überprüfung und nicht das Überschreiben ist.
  • Nutzen Sie diskriminierte Unionen sowie never, damit der Compiler erkennen kann, ob ein bestimmter Zustand tatsächlich auftreten kann.
  • Ziel ist es nicht, die ausgefeiltesten Typen zu verwenden, sondern Code, bei dem der Compiler bereits vor dem Ausführen des Programms beantworten kann: „Kann dieser Zustand auftreten?“ Nutzen Sie TypeScript, um sichereren Code zu entwerfen – und nicht nur, um den bereits geschriebenen Code zu beschreiben.

    Verwandte Literatur

  • Zehn TypeScript-Muster, die Laufzeitfehler in Kompilierfehler umwandeln — Lernen Sie zehn TypeScript-Techniken, von diskriminierten Unionen und satisfies bis hin zu branded Types und infer, die es dem Compiler ermöglichen, ungültige Zustände bereits vor der Veröffentlichung Ihres Codes abzulehnen.
  • Was der TypeScript-Kompilator erfasst, was JavaScript durchlässt — Vergleichen Sie JavaScript und TypeScript Seite an Seite: Typinferenz, Annotationen, Primitive, any, Unionen sowie typisierte Funktionen, plus das, was tsc tatsächlich als Fehler ausweist und meldet.