Zum Inhalt springen
20 Min. Lesezeit

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.

Teilen:X / TwitterLinkedIn

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:

typescript
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:

typescript
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:

typescript
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
type C = IsString<string>;  // true

Das 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:

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// Ergebnis: string

Der 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:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Ergebnis: number | boolean

Exclude — das Gegenteil, entfernt passende Mitglieder:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Ergebnis: number | boolean

Distributive 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.

typescript
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:

typescript
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:

typescript
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: string

Das 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#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

TypeScript sieht das Muster (...args) => etwas, gleicht deine Funktion damit ab und weist den Rückgabetyp R zu.

Parametertypen extrahieren#

typescript
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:

typescript
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:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Ergebnis: number

Array-Elementtypen extrahieren#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

Mehrfaches infer in einem Typ#

Du kannst infer mehrfach verwenden, um verschiedene Teile zu extrahieren:

typescript
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:

typescript
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#

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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#

typescript
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:

typescript
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:

typescript
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:

typescript
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");  // Fehler

Strings parsen mit Template Literals#

Du kannst Template Literal Types mit infer kombinieren, um Strings zu parsen:

typescript
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:

typescript
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:

typescript
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:

typescript
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: string

Volle 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:

typescript
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 optional

Beachte 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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' fehlt

Die 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:

typescript
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 AppEvents

Dieses 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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 UserId

Sowohl 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".

typescript
// 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 --noEmit dauert nach dem Hinzufügen eines Typs merklich länger
  • Du siehst „Type instantiation is excessively deep"-Fehler
typescript
// 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 Elementen

Wann 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
typescript
// Ü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:

typescript
// 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 sehen

Der & {}-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:

typescript
// 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üfen

TL;DR#

  • Conditional Types (T extends U ? X : Y) sind if/else für Typen. Achte auf distributives Verhalten bei Unions.
  • infer ist 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 mit as fü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.

Ähnliche Beiträge