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.
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.unknown anstelle von any und satisfies anstelle von as, wenn Ihr Ziel die Überprüfung und nicht das Überschreiben ist.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
- Sechs TypeScript-Techniken, die Typen in echte Fehlerprävention verwandeln — Erfahren Sie, wie satisfies, tagged Unions, never Checks, unknown, abgeleitete Typen und branded IDs dazu beitragen, dass TypeScript echte Fehler bereits zur Kompilierzeit aufspürt und nicht erst in der Produktion.
- Modellierung von Domänen in TypeScript: Jenseits einfacher Typ-Anmerkungen — Lernen Sie praktische TypeScript-Methoden – von unknown vs any über diskriminierte Unions bis zu satisfies –, die Ihnen helfen, gültige Zustände zu modellieren anstatt Daten lediglich zu kennzeichnen.