Fortgeschrittene TypeScript-Typen: Conditional, Mapped und Template Literal Magic
Ein tiefer Einblick in TypeScripts mächtigste Type-Features — Conditional Types, Mapped Types, das infer-Keyword und Template Literal Types. Echte Patterns, die du tatsächlich verwenden wirst.
Die meisten TypeScript-Entwickler bleiben am gleichen Punkt stehen. Sie kennen Interfaces, Generics, Union Types. Sie können eine React-Komponente typisieren. Sie können die roten Schlangenlinien zum Verschwinden bringen. Aber sie behandeln das Typsystem als notwendiges Übel — etwas, das existiert, um Bugs zu verhindern, nicht etwas, das ihnen aktiv hilft, bessere Software zu entwerfen.
Ich bin etwa zwei Jahre auf diesem Plateau geblieben. Was mich herauszog, war die Erkenntnis, dass TypeScripts Typsystem eine eigenständige Programmiersprache ist. Es hat Conditionals, Schleifen, Pattern Matching, Rekursion und String-Manipulation. Sobald du das verinnerlicht hast, ändert sich alles. Du hörst auf, gegen den Compiler zu kämpfen, und fängst an, mit ihm zusammenzuarbeiten.
Dieser Beitrag behandelt die Type-Level-Features, die ich ständig in Produktionscode verwende. Keine akademischen Übungen — echte Patterns, die mich vor echten Bugs bewahrt haben.
Die Denkweise der Type-Level-Programmierung#
Bevor wir in die Syntax eintauchen, möchte ich deine Denkweise über Typen neu ausrichten.
Bei der Value-Level-Programmierung schreibst du Funktionen, die Daten zur Laufzeit transformieren:
function extractIds(users: User[]): number[] {
return users.map(u => u.id);
}Bei der Type-Level-Programmierung schreibst du Typen, die andere Typen zur Kompilierzeit transformieren:
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];Das mentale Modell ist dasselbe — Input, Transformation, Output. Der Unterschied ist, dass Type-Level-Code während der Kompilierung ausgeführt wird, nicht zur Laufzeit. Er produziert kein JavaScript. Sein einziger Zweck ist es, unmögliche Zustände nicht darstellbar zu machen.
Wenn du jemals eine Funktion geschrieben hast, bei der drei von vier Parametern nur in bestimmten Kombinationen Sinn ergeben, oder bei der der Rückgabetyp davon abhängt, was du übergibst, hast du Type-Level-Programmierung bereits gebraucht. Du wusstest vielleicht nur nicht, dass TypeScript das ausdrücken kann.
Conditional Types#
Conditional Types sind das if/else des Typsystems. Die Syntax sieht aus wie ein Ternär:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // trueDas Keyword extends bedeutet hier nicht Vererbung. Es bedeutet „ist zuweisbar an." Stell es dir vor als: „Passt T in die Form von string?"
Eigene Utility Types bauen#
Lass uns einige von TypeScripts eingebauten Utility Types nachbauen, um zu verstehen, wie sie funktionieren.
NonNullable — entfernt null und undefined aus einer Union:
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Test = MyNonNullable<string | null | undefined>;
// Ergebnis: stringDer Typ never ist die „leere Menge" — er entfernt etwas aus einer Union. Wenn ein Zweig eines Conditional Type zu never aufgelöst wird, verschwindet dieses Mitglied der Union einfach.
Extract — behält nur Union-Mitglieder, die einer Einschränkung entsprechen:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// Ergebnis: number | booleanExclude — das Gegenteil, entfernt passende Mitglieder:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// Ergebnis: number | booleanDistributive Conditional Types#
Hier wird es interessant, und hier geraten die meisten Leute durcheinander. Wenn du einen Union Type an einen Conditional Type übergibst, verteilt TypeScript die Bedingung über jedes Mitglied der Union einzeln.
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// Man könnte erwarten: (string | number)[]
// Tatsächliches Ergebnis: string[] | number[]TypeScript evaluiert nicht (string | number) extends unknown. Stattdessen evaluiert es string extends unknown und number extends unknown separat und vereinigt dann die Ergebnisse.
Deshalb funktionieren Extract und Exclude so, wie sie es tun. Die Verteilung geschieht automatisch, wenn T ein „nackter" Typparameter ist (nicht in etwas eingewickelt).
Wenn du die Verteilung verhindern willst, wickle beide Seiten in ein Tupel:
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Result = ToArrayNonDist<string | number>;
// Ergebnis: (string | number)[]Das hat mich öfter erwischt, als mir lieb ist. Wenn dein Conditional Type bei Union-Inputs unerwartete Ergebnisse liefert, ist die Distribution fast immer der Grund.
Praxisbeispiel: API-Response-Handler#
Hier ist ein Pattern, das ich bei API-Antworten verwende, die entweder erfolgreich sein oder fehlschlagen können:
type ApiResponse<T> =
| { status: "success"; data: T; error: never }
| { status: "error"; data: never; error: string };
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
type UserResponse = ApiResponse<{ id: number; name: string }>;
type UserData = ExtractData<UserResponse>;
// Ergebnis: { id: number; name: string }
type UserError = ExtractError<UserResponse>;
// Ergebnis: stringDas ist sauberer als manuelle Type Guards für jede Response-Form zu schreiben.
Das infer-Keyword#
infer ist Pattern Matching für Typen. Es erlaubt dir, eine Typvariable innerhalb eines Conditional Types zu deklarieren, die TypeScript für dich herausfindet.
Stell es dir vor als: „Ich weiß, dass dieser Typ eine bestimmte Form hat. Zieh den Teil heraus, der mich interessiert."
Rückgabetypen extrahieren#
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // booleanTypeScript sieht das Muster (...args) => etwas, gleicht deine Funktion damit ab und weist den Rückgabetyp R zu.
Parametertypen extrahieren#
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]Promises auspacken#
Dieses Pattern verwende ich ständig bei der Arbeit mit Async-Funktionen:
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnpackPromise<Promise<string>>; // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>; // string (Durchleitung)Für tief verschachtelte Promises:
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Ergebnis: numberArray-Elementtypen extrahieren#
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberMehrfaches infer in einem Typ#
Du kannst infer mehrfach verwenden, um verschiedene Teile zu extrahieren:
type FirstAndLast<T extends unknown[]> =
T extends [infer First, ...unknown[], infer Last]
? { first: First; last: Last }
: never;
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Ergebnis: { first: 1; last: 4 }Praxisbeispiel: Typsichere Event-Extraktion#
Hier ist ein Pattern, das ich in eventgesteuerten Systemen verwende:
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"order:created": { orderId: string; total: number };
"order:shipped": { orderId: string; trackingId: string };
};
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
// Alle User-bezogenen Events extrahieren
type UserEvents = Extract<EventName, `user:${string}`>;
// Ergebnis: "user:login" | "user:logout"
// Payload nur für User-Events abrufen
type UserEventPayloads = EventPayload<UserEvents>;
// Ergebnis: { userId: string; timestamp: number } | { userId: string }Mapped Types#
Mapped Types ermöglichen es dir, neue Objekttypen zu erstellen, indem du jede Eigenschaft eines bestehenden Typs transformierst. Sie sind das map() des Typsystems.
Die Grundlagen#
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};keyof T gibt dir eine Union aller Property-Keys. [K in ...] iteriert über jeden einzelnen. T[K] ist der Typ dieser Property.
Utility Types von Grund auf bauen#
Required — macht alle Properties verpflichtend:
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};Die -?-Syntax entfernt den optionalen Modifikator. Analog entfernt -readonly das Readonly.
Pick — wählt bestimmte Properties aus:
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
interface User {
id: number;
name: string;
email: string;
password: string;
}
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }Omit — entfernt bestimmte Properties:
type MyOmit<T, K extends keyof T> = {
[P in Exclude<keyof T, K>]: T[P];
};
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }Record — erstellt einen Objekttyp mit bestimmten Keys und Value-Typen:
type MyRecord<K extends string | number | symbol, V> = {
[P in K]: V;
};
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }Key Remapping mit as#
TypeScript 4.1 führte Key Remapping ein, das dir erlaubt, Property-Namen zu transformieren:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
email: string;
}
type PersonGetters = Getters<Person>;
// {
// getName: () => string;
// getAge: () => number;
// getEmail: () => string;
// }Du kannst auch Properties filtern, indem du zu never remappst:
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }Das ist unglaublich mächtig. Du iterierst gleichzeitig über Properties, filterst sie und transformierst ihre Namen — alles auf Type-Ebene.
Praxisbeispiel: Formularvalidierung-Typen#
Dieses Pattern habe ich in mehreren Form-Libraries verwendet:
interface FormFields {
username: string;
email: string;
age: number;
bio: string;
}
type ValidationErrors<T> = {
[K in keyof T]?: string[];
};
type TouchedFields<T> = {
[K in keyof T]?: boolean;
};
type DirtyFields<T> = {
[K in keyof T]?: boolean;
};
type FormState<T> = {
values: T;
errors: ValidationErrors<T>;
touched: TouchedFields<T>;
dirty: DirtyFields<T>;
isValid: boolean;
isSubmitting: boolean;
};
// Jetzt ist dein Formular-State vollständig typisiert basierend auf den Feldern
type MyFormState = FormState<FormFields>;Füge ein Feld zu FormFields hinzu, und jeder verwandte Typ aktualisiert sich automatisch. Entferne ein Feld, und der Compiler fängt jede Referenz ab. Das ist die Art von Sache, die ganze Kategorien von Bugs verhindert.
Template Literal Types#
Template Literal Types ermöglichen dir die Manipulation von Strings auf Type-Ebene. Sie verwenden die gleiche Backtick-Syntax wie JavaScript-Template-Literals, aber für Typen.
Grundlegende String-Manipulation#
type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, world"; // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world"; // Fehler!TypeScript bietet auch eingebaute String-Manipulationstypen:
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"Event-Name-Patterns#
Hier zeigen Template Literal Types ihre volle Stärke. Hier ist ein Pattern, das ich für typsichere Eventsysteme verwende:
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"Neun Events aus zwei Typparametern. Und sie sind alle typsicher.
CSS-in-TypeScript-Patterns#
Template Literal Types können CSS-Patterns auf Type-Ebene erzwingen:
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
function setWidth(value: CSSValue): void {
// Implementierung
}
setWidth("100px"); // OK
setWidth("2.5rem"); // OK
setWidth("50%"); // OK
// setWidth("100"); // Fehler: kein gültiger CSS-Wert
// setWidth("big"); // FehlerStrings parsen mit Template Literals#
Du kannst Template Literal Types mit infer kombinieren, um Strings zu parsen:
type ParseRoute<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | ParseRoute<`/${Rest}`>
: T extends `${infer _Start}:${infer Param}`
? Param
: never;
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Ergebnis: "userId" | "postId"Das ist die Grundlage dafür, wie typsichere Routing-Libraries wie tRPC funktionieren. Der String /users/:userId/posts/:postId wird auf Type-Ebene geparst, um Parameternamen zu extrahieren.
Template Literals mit Mapped Types kombinieren#
Hier wird es richtig mächtig:
type EventHandlers<T extends Record<string, unknown>> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (
newValue: T[K],
oldValue: T[K]
) => void;
};
interface ThemeConfig {
color: string;
fontSize: number;
darkMode: boolean;
}
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
// onColorChange: (newValue: string, oldValue: string) => void;
// onFontSizeChange: (newValue: number, oldValue: number) => void;
// onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }Aus einem einfachen Config-Interface bekommst du vollständig typisierte Event-Handler mit korrekten Parametertypen. Das ist die Art von Type-Level-Code, die tatsächlich Entwicklungszeit spart.
Fortgeschritten: Dot-Notation-Pfadtypen#
Hier ist ein fortgeschritteneres Pattern — die Generierung aller möglichen Dot-Notation-Pfade durch ein verschachteltes Objekt:
type DotPaths<T, Prefix extends string = ""> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string]
: never;
interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
server: {
port: number;
};
}
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"Jetzt kannst du eine getConfig-Funktion schreiben, die jeden möglichen Pfad autovervollständigt und den korrekten Typ für jeden zurückgibt:
type GetValueByPath<T, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? GetValueByPath<T[Key], Rest>
: never
: P extends keyof T
? T[P]
: never;
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
// Implementierung
const keys = path.split(".");
let result: unknown = config;
for (const key of keys) {
result = (result as Record<string, unknown>)[key];
}
return result as GetValueByPath<Config, P>;
}
const host = getConfig("database.host"); // Typ: string
const port = getConfig("database.port"); // Typ: number
const username = getConfig("database.credentials.username"); // Typ: stringVolle Autovervollständigung, volle Typsicherheit, kein Runtime-Overhead durch die Typen.
Rekursive Typen#
TypeScript unterstützt rekursive Typdefinitionen, was dir erlaubt, beliebig verschachtelte Strukturen zu behandeln.
Deep Partial#
Das eingebaute Partial<T> macht nur Properties der obersten Ebene optional. Für verschachtelte Objekte brauchst du Rekursion:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object
? T[K] extends Array<infer U>
? Array<DeepPartial<U>>
: DeepPartial<T[K]>
: T[K];
};
interface Settings {
theme: {
colors: {
primary: string;
secondary: string;
};
fonts: {
heading: string;
body: string;
};
};
notifications: {
email: boolean;
push: boolean;
};
}
type PartialSettings = DeepPartial<Settings>;
// Jede verschachtelte Property ist jetzt optionalBeachte den Array-Check: Ohne ihn würden Arrays als Objekte behandelt und ihre numerischen Indizes würden optional werden, was du nicht willst.
Deep Readonly#
Gleiches Pattern, anderer Modifikator:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: T[K] extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: DeepReadonly<T[K]>
: T[K];
};Der Function-Check ist wichtig — ohne ihn würde readonly auf die interne Struktur von Funktionseigenschaften angewendet, was keinen Sinn ergibt.
JSON-Typ#
Hier ist ein klassischer rekursiver Typ — die Darstellung jedes gültigen JSON-Werts:
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
function parseJson(input: string): JsonValue {
return JSON.parse(input);
}
function isJsonObject(value: JsonValue): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}Das ist wesentlich besser als any für JSON-Parsing zu verwenden. Es sagt dem Konsumenten: „Du bekommst etwas zurück, aber du musst es einschränken, bevor du es verwendest." Was genau der Wahrheit entspricht.
Rekursiver Typ für verschachtelte Menüs#
Ein praktisches Beispiel — das Typisieren einer Navigationsmenüstruktur:
interface MenuItem {
label: string;
href: string;
icon?: string;
children?: MenuItem[];
}
type Menu = MenuItem[];
const navigation: Menu = [
{
label: "Products",
href: "/products",
children: [
{
label: "Software",
href: "/products/software",
children: [
{ label: "IDE", href: "/products/software/ide" },
{ label: "CLI Tools", href: "/products/software/cli" },
],
},
{ label: "Hardware", href: "/products/hardware" },
],
},
{ label: "About", href: "/about" },
];Die rekursive children-Eigenschaft gibt dir unendliche Verschachtelung mit voller Typsicherheit.
Praxisnahe Patterns#
Lass mich einige Patterns teilen, die ich tatsächlich in Produktionssystemen verwendet habe.
Builder-Pattern mit Typen#
Das Builder-Pattern wird deutlich nützlicher, wenn das Typsystem verfolgt, was gesetzt wurde:
type RequiredKeys = "host" | "port" | "database";
type BuilderState = {
[K in RequiredKeys]?: true;
};
class DatabaseConfigBuilder<State extends BuilderState = {}> {
private config: Partial<{
host: string;
port: number;
database: string;
ssl: boolean;
poolSize: number;
}> = {};
setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
this.config.host = host;
return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
}
setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
this.config.port = port;
return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
}
setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
this.config.database = db;
return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
}
setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
this.config.ssl = ssl;
return this as unknown as DatabaseConfigBuilder<State>;
}
setPoolSize(size: number): DatabaseConfigBuilder<State> {
this.config.poolSize = size;
return this as unknown as DatabaseConfigBuilder<State>;
}
build(
this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
): Required<Pick<typeof this.config, "host" | "port" | "database">> &
Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
return this.config as ReturnType<typeof this.build>;
}
}
// Das kompiliert:
const config = new DatabaseConfigBuilder()
.setHost("localhost")
.setPort(5432)
.setDatabase("myapp")
.setSsl(true)
.build();
// Das schlägt zur Kompilierzeit fehl — .setDatabase() fehlt:
// const bad = new DatabaseConfigBuilder()
// .setHost("localhost")
// .setPort(5432)
// .build(); // Fehler: 'database' fehltDie build()-Methode wird erst aufrufbar, wenn alle Pflichtfelder gesetzt wurden. Das wird zur Kompilierzeit erkannt, nicht zur Laufzeit.
Typsicherer Event Emitter#
Hier ist ein Event Emitter, bei dem die Payload-Typen erzwungen werden:
type EventMap = Record<string, unknown>;
class TypedEventEmitter<Events extends EventMap> {
private listeners: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
on<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners[event]?.forEach((listener) => listener(payload));
}
off<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
const handlers = this.listeners[event];
if (handlers) {
this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
}
}
}
// Verwendung
interface AppEvents {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"notification:new": { message: string; level: "info" | "warning" | "error" };
}
const emitter = new TypedEventEmitter<AppEvents>();
// Vollständig typisiert — IDE vervollständigt Event-Namen und Payload-Formen
emitter.on("user:login", (payload) => {
console.log(payload.userId); // string
console.log(payload.timestamp); // number
});
// Typfehler: Payload stimmt nicht überein
// emitter.emit("user:login", { userId: "123" });
// Fehler: 'timestamp' fehlt
// Typfehler: Event existiert nicht
// emitter.on("user:signup", () => {});
// Fehler: "user:signup" ist nicht in AppEventsDieses Pattern habe ich in drei verschiedenen Produktionsprojekten verwendet. Es fängt ganze Kategorien von eventbezogenen Bugs zur Kompilierzeit ab.
Discriminated Unions mit Exhaustive Checking#
Discriminated Unions sind wahrscheinlich das einzeln nützlichste Pattern in TypeScript. Kombiniert mit Exhaustive Checking garantieren sie, dass du jeden Fall behandelst:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
// Diese Zeile stellt Exhaustive Checking sicher
const _exhaustive: never = shape;
return _exhaustive;
}
}Wenn jemand eine neue Shape-Variante hinzufügt (sagen wir "pentagon"), wird diese Funktion nicht kompilieren, weil never kein Wert zugewiesen werden kann. Der Compiler zwingt dich, jeden Fall zu behandeln.
Ich gehe noch weiter mit einer Hilfsfunktion:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
}
}Typsichere State Machines#
Discriminated Unions modellieren auch State Machines wunderbar:
type ConnectionState =
| { status: "disconnected" }
| { status: "connecting"; attempt: number }
| { status: "connected"; socket: WebSocket; connectedAt: Date }
| { status: "error"; error: Error; lastAttempt: Date };
type ConnectionAction =
| { type: "CONNECT" }
| { type: "CONNECTED"; socket: WebSocket }
| { type: "DISCONNECT" }
| { type: "ERROR"; error: Error }
| { type: "RETRY" };
function connectionReducer(
state: ConnectionState,
action: ConnectionAction
): ConnectionState {
switch (state.status) {
case "disconnected":
if (action.type === "CONNECT") {
return { status: "connecting", attempt: 1 };
}
return state;
case "connecting":
if (action.type === "CONNECTED") {
return {
status: "connected",
socket: action.socket,
connectedAt: new Date(),
};
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "connected":
if (action.type === "DISCONNECT") {
state.socket.close();
return { status: "disconnected" };
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "error":
if (action.type === "RETRY") {
return { status: "connecting", attempt: 1 };
}
if (action.type === "DISCONNECT") {
return { status: "disconnected" };
}
return state;
default:
assertNever(state, "Unknown connection state");
}
}Jeder Status hat nur die Properties, die für diesen Status Sinn ergeben. Du kannst nicht auf socket zugreifen, wenn du disconnected bist. Du kannst nicht auf error zugreifen, wenn du connected bist. Das Typsystem erzwingt die Einschränkungen der State Machine.
Branded Types für Domänensicherheit#
Noch ein Pattern, das ich für unverzichtbar halte — die Verwendung von Branded Types, um das Verwechseln von Werten zu verhindern, die zufällig denselben zugrundeliegenden Typ teilen:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getUser(id: UserId): Promise<User> {
// ...
}
function getOrder(id: OrderId): Promise<Order> {
// ...
}
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
getUser(userId); // OK
// getUser(orderId); // Fehler! OrderId ist nicht zuweisbar an UserIdSowohl UserId als auch OrderId sind zur Laufzeit Strings. Aber zur Kompilierzeit sind sie unterschiedliche Typen. Du kannst buchstäblich keine Order-ID dort übergeben, wo eine User-ID erwartet wird. Das hat in jedem Projekt, in dem ich es verwendet habe, echte Bugs gefangen.
Häufige Fallstricke#
Fortgeschrittene Typen sind mächtig, aber sie kommen mit Fallen. Hier ist, was ich auf die harte Tour gelernt habe.
Zirkuläre Referenzbeschränkungen#
TypeScript hat eine Rekursionstiefenbeschränkung für Typen (derzeit etwa 50 Ebenen, wobei es variiert). Wenn du zu tief gehst, bekommst du den gefürchteten Fehler „Type instantiation is excessively deep and possibly infinite".
// Das wird die Rekursionsgrenze bei tief verschachtelten Objekten erreichen
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// Fix: Einen Tiefenzähler hinzufügen
type DeepPartialSafe<T, Depth extends number[] = []> =
Depth["length"] extends 10
? T
: {
[K in keyof T]?: T[K] extends object
? DeepPartialSafe<T[K], [...Depth, 0]>
: T[K];
};Der Tiefenzähler-Trick verwendet ein Tupel, das mit jedem rekursiven Schritt wächst. Wenn es dein Limit erreicht, stoppt die Rekursion.
Auswirkungen auf die Performance#
Komplexe Typen können deinen Editor und die Build-Zeiten erheblich verlangsamen. Ich habe Projekte gesehen, in denen ein einzelner übermäßig cleverer Typ 3 Sekunden zur Feedback-Schleife jedes Tastenanschlags hinzufügte.
Warnsignale:
- Deine IDE braucht mehr als 2 Sekunden, um Autovervollständigung anzuzeigen
tsc --noEmitdauert nach dem Hinzufügen eines Typs merklich länger- Du siehst „Type instantiation is excessively deep"-Fehler
// Das ist zu clever — es erzeugt eine kombinatorische Explosion
type AllCombinations<T extends string[]> = T extends [
infer First extends string,
...infer Rest extends string[]
]
? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
: "";
// Mach das nicht mit mehr als 5-6 ElementenWann man fortgeschrittene Typen NICHT verwenden sollte#
Das ist vielleicht der wichtigste Abschnitt. Fortgeschrittene Typen sollten verwendet werden, wenn sie Bugs verhindern oder die Entwicklererfahrung verbessern. Sie sollten nicht zum Angeben verwendet werden.
Verwende sie nicht, wenn:
- Ein einfaches
Record<string, unknown>ausreichen würde - Der Typ nur an einer Stelle verwendet wird und ein konkreter Typ klarer wäre
- Du mehr Zeit mit dem Debuggen des Typs verbringst, als er einsparen würde
- Dein Team ihn nicht lesen oder warten kann
- Ein Laufzeit-Check angemessener wäre
Verwende sie, wenn:
- Du ein Pattern hast, das sich über viele Typen wiederholt (Mapped Types)
- Der Rückgabetyp einer Funktion von ihrem Input abhängt (Conditional Types)
- Du eine Library-API baust, die selbstdokumentierend sein muss
- Du ungültige Zustände nicht darstellbar machen willst
- Du es leid bist, dass dieselbe Kategorie von Bug in Code Reviews auftaucht
// Überengineered — mach das nicht für eine einfache Konfiguration
type Config = DeepReadonly<
DeepRequired<
Merge<DefaultConfig, Partial<UserConfig>>
>
>;
// Mach einfach das
interface Config {
readonly host: string;
readonly port: number;
readonly debug: boolean;
}Meine Faustregel: Wenn die Erklärung des Typs länger dauert als die Erklärung des Bugs, den er verhindert — vereinfache ihn.
Komplexe Typen debuggen#
Wenn ein komplexer Typ nicht funktioniert, verwende ich diesen Helper, um zu „sehen", was TypeScript aufgelöst hat:
// Expandiert einen Typ zur Inspektion in IDE-Hover-Tooltips
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
// Verwende es zum Debuggen
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Fahre jetzt mit der Maus über Debug in deiner IDE, um den aufgelösten Typ zu sehenDer & {}-Trick zwingt TypeScript, den Typ eifrig zu evaluieren, anstatt dir den Type-Alias anzuzeigen. Er hat mir Stunden der Verwirrung erspart.
Eine weitere Technik — isolieren und inkrementell testen:
// Anstatt das alles auf einmal zu debuggen:
type Final = StepThree<StepTwo<StepOne<Input>>>;
// Zerlege es:
type AfterStep1 = StepOne<Input>; // hover zum Prüfen
type AfterStep2 = StepTwo<AfterStep1>; // hover zum Prüfen
type AfterStep3 = StepThree<AfterStep2>; // hover zum PrüfenTL;DR#
- Conditional Types (
T extends U ? X : Y) sind if/else für Typen. Achte auf distributives Verhalten bei Unions. inferist Pattern Matching — verwende es, um Typen aus Strukturen wie Funktionssignaturen, Promises und Arrays zu extrahieren.- Mapped Types (
{ [K in keyof T]: ... }) iterieren über Properties. Kombiniere mitasfür Key Remapping und Filterung. - Template Literal Types manipulieren Strings auf Type-Ebene. Kombiniert mit Mapped Types sind sie unglaublich mächtig für API-Design.
- Rekursive Typen behandeln verschachtelte Strukturen, brauchen aber Tiefenbegrenzungen, um Compiler-Explosionen zu vermeiden.
- Discriminated Unions mit Exhaustive Checking sind das einzeln wertvollste Pattern in TypeScript. Verwende sie überall.
- Branded Types verhindern das Verwechseln von Werten, die denselben zugrundeliegenden Typ teilen. Einfach zu implementieren, hohe Wirkung.
- Überengineere Typen nicht. Wenn der Typ schwerer zu verstehen ist als der Bug, den er verhindert, vereinfache ihn. Das Ziel ist es, deine Codebasis sicherer zu machen, nicht einen Type-Golf-Wettbewerb zu gewinnen.
Das TypeScript-Typsystem ist Turing-vollständig, was bedeutet, dass du fast alles damit machen kannst. Die Kunst besteht darin zu wissen, wann du es solltest.