Zum Inhalt springen
Webentwicklung

TypeScript Conditional Types: Typsystem als Logik-Engine

10 min Lesezeit
TypeScript Types Generics

TypeScripts Typsystem ist Turing-complete. Das ist keine akademische Kurositaet — es bedeutet, dass das Typsystem selbst eine Programmiersprache ist. Conditional Types sind die if-Statements dieser Sprache. infer ist Pattern Matching. Template Literal Types sind String-Verarbeitung. Zusammen ermöglichen sie Typ-Logik, die zur Compile-Zeit ausgewertet wird und zur Laufzeit komplett verschwindet.

Das ist maechtig. Es ist auch gefaehrlich. Denn die Grenze zwischen nuetzlicher Typ-Sicherheit und unlesbarem Typ-Origami ist duenn. Dieser Artikel zeigt, wo sich die Investition lohnt — und wo man aufhoeren sollte.

Conditional Types: if-else für Typen

Das Grundmuster ist simpel: T extends U ? X : Y. Wenn T zu U zuweisbar ist, ergibt der Typ X, sonst Y. Das liest sich wie ein Ternary-Operator — und verhaelt sich auch so.

Conditional Types Grundlagen typescript
// Einfacher Typ-Check
type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">;  // true
type B = IsString<42>;       // false

// Praktischer: Rueckgabetyp basierend auf Input
type ApiResponse<T> = T extends "user"
  ? { id: string; name: string; email: string }
  : T extends "post"
    ? { id: string; title: string; body: string }
    : never;

// Der Typ wird zur Compile-Zeit aufgeloest
type UserResponse = ApiResponse<"user">;
// { id: string; name: string; email: string }

type PostResponse = ApiResponse<"post">;
// { id: string; title: string; body: string }

type InvalidResponse = ApiResponse<"comment">;
// never -- Compile-Fehler bei Verwendung

Das never-Pattern am Ende ist wichtig. Es erzwingt, dass nur gueltige Inputs akzeptiert werden. Jeder Versuch, einen Wert vom Typ never zu verwenden, fuehrt zu einem Compile-Fehler. Das ist exhaustive Type Checking ohne Switch-Statement.

infer: Pattern Matching im Typsystem

infer ist das maechtigste Keyword in TypeScripts Typ-Vocabulary. Es extrahiert Typen aus anderen Typen — wie Regex-Capture-Groups, aber für Typen.

infer für Typ-Extraktion typescript
// Return-Typ einer Funktion extrahieren
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;

type Fn = (x: number, y: string) => boolean;
type Result = ReturnOf<Fn>;  // boolean

// Promise unwrappen -- auch verschachtelt
type UnwrapPromise<T> = T extends Promise<infer U>
  ? UnwrapPromise<U>  // Rekursiv!
  : T;

type Deep = Promise<Promise<Promise<string>>>;
type Unwrapped = UnwrapPromise<Deep>;  // string

// Array-Element-Typ extrahieren
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;

type Arr = string[];
type El = ElementOf<Arr>;  // string

// Erster und letzter Typ eines Tuples
type First<T extends readonly any[]> =
  T extends readonly [infer F, ...any[]] ? F : never;

type Last<T extends readonly any[]> =
  T extends readonly [...any[], infer L] ? L : never;

type Tuple = [string, number, boolean];
type F = First<Tuple>;  // string
type L = Last<Tuple>;   // boolean

Die rekursive UnwrapPromise-Definition ist bemerkenswert. TypeScript erlaubt rekursive Conditional Types — solange die Rekursion terminiert. Das oeffnet die Tuer für komplexe Typ-Transformationen, die beliebig tief verschachtelte Strukturen verarbeiten können.

Distributive Conditional Types: Die groesste Falle

Wenn ein Conditional Type auf einen Union Type angewendet wird, passiert etwas Unerwartetes: TypeScript verteilt die Bedingung auf jeden Member des Unions. Das heisst T extends U ? X : Y wird für jeden Teil von T einzeln ausgewertet.

Distributive Conditional Types typescript
type ToArray<T> = T extends any ? T[] : never;

// Man erwartet: (string | number)[]
// Man bekommt: string[] | number[]
type Result = ToArray<string | number>;
// string[] | number[]

// Das ist oft nuetzlich -- z.B. für Exclude und Extract
type Exclude<T, U> = T extends U ? never : T;

type WithoutNull = Exclude<string | number | null, null>;
// string | number

// Aber manchmal will man die Distribution verhindern.
// Lösung: T in ein Tuple wrappen
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

type Result2 = ToArrayNonDist<string | number>;
// (string | number)[]

Die eingebauten Utility Types Exclude, Extract, NonNullable und Omit basieren alle auf distributiven Conditional Types. Wer versteht, wie Distribution funktioniert, versteht diese Utility Types — und kann eigene bauen.

Template Literal Types: String-Manipulation zur Compile-Zeit

Seit TypeScript 4.1 können String-Typen mit Template-Syntax manipuliert werden. Das klingt nach einem Nischenfeature. In der Praxis ist es ein Game-Changer für API-Design.

Template Literal Types in der Praxis typescript
// Event-Namen generieren
type EventName<T extends string> = `on${Capitalize<T>}`;

type ClickEvent = EventName<"click">;     // "onClick"
type ChangeEvent = EventName<"change">;   // "onChange"

// CSS-Property-Typen
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;

// Nur gueltige CSS-Werte erlaubt
const valid: CSSValue = "16px";     // OK
const also: CSSValue = "1.5rem";    // OK
// const invalid: CSSValue = "16";  // Fehler!

// Getter aus Objekt-Keys ableiten
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

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

type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }

// String parsen mit infer
type ParseRoute<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ParseRoute<Rest>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;

type Params = ParseRoute<"/users/:userId/posts/:postId">;
// "userId" | "postId"

Der ParseRoute-Typ ist ein perfektes Beispiel für die Synergie zwischen Template Literal Types und infer. Er extrahiert zur Compile-Zeit alle Route-Parameter aus einem URL-Pattern. Frameworks wie Hono und tRPC nutzen genau dieses Pattern für type-safe Routing.

Praktische Patterns für den Alltag

Die bisherigen Beispiele waren illustrativ. Jetzt wird es praktisch. Drei Patterns, die in echten Codebasen sofort Wert liefern.

Deep Readonly

Readonly<T> von TypeScript ist shallow — es schuetzt nur die oberste Ebene. Fuer verschachtelte Objekte braucht man eine rekursive Variante:

Deep Readonly mit rekursiven Conditional Types typescript
type DeepReadonly<T> =
  T extends (...args: any[]) => any
    ? T  // Funktionen nicht veraendern
    : T extends Map<infer K, infer V>
      ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
      : T extends Set<infer U>
        ? ReadonlySet<DeepReadonly<U>>
        : T extends object
          ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
          : T;  // Primitive bleiben wie sie sind

interface AppState {
  user: {
    profile: {
      name: string;
      settings: {
        theme: "light" | "dark";
        notifications: boolean;
      };
    };
    posts: Array<{ id: string; title: string }>;
  };
}

type ImmutableState = DeepReadonly<AppState>;

// Jetzt sind alle Ebenen readonly:
// state.user.profile.settings.theme = "dark"  -- Fehler!
// state.user.posts.push(...)                   -- Fehler!
// state.user.posts[0].title = "Neu"            -- Fehler!

Das Pattern behandelt Sonderfaelle: Funktionen werden durchgereicht (ein readonly auf einer Funktion ergibt keinen Sinn), Map und Set werden in ihre Readonly-Varianten konvertiert, und primitive Typen bleiben unveraendert. Erst bei object greift die rekursive Transformation.

Path Types für verschachtelte Objekte

Wer mit State-Management-Libraries oder Formularen arbeitet, kennt das Problem: Man will auf verschachtelte Properties über einen Pfad-String zugreifen — type-safe.

Type-safe Dot-Notation Paths typescript
type Primitive = string | number | boolean | null | undefined;

type Path<T, Prefix extends string = ""> =
  T extends Primitive
    ? never
    : {
        [K in keyof T & string]:
          | `${Prefix}${K}`
          | Path<T[K], `${Prefix}${K}.`>
      }[keyof T & string];

interface FormData {
  name: string;
  address: {
    street: string;
    city: string;
    zip: number;
  };
  contacts: {
    email: string;
    phone: string;
  };
}

type FormPath = Path<FormData>;
// "name" | "address" | "address.street" | "address.city"
// | "address.zip" | "contacts" | "contacts.email"
// | "contacts.phone"

// Typ an einem Pfad aufloesen
type PathValue<T, P extends string> =
  P extends `${infer K}.${infer Rest}`
    ? K extends keyof T
      ? PathValue<T[K], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;

// Nutzung: type-safe getter
function getValue<T, P extends Path<T>>(
  obj: T,
  path: P
): PathValue<T, P> {
  return path.split('.').reduce(
    (acc: any, key) => acc[key], obj
  ) as PathValue<T, P>;
}

const form: FormData = {
  name: "Max",
  address: { street: "Hauptstr. 1", city: "Berlin", zip: 10115 },
  contacts: { email: "max@example.com", phone: "+49..." },
};

const city = getValue(form, "address.city");
// Typ: string -- automatisch aufgeloest!

const zip = getValue(form, "address.zip");
// Typ: number

// getValue(form, "address.invalid")
// Compile-Fehler: not assignable to Path<FormData>

Type-safe Event Emitter

Das dritte Pattern kombiniert Template Literal Types mit Conditional Types für einen Event Emitter, der zur Compile-Zeit prueft, ob Event-Name und Payload zusammenpassen:

Type-safe Event Emitter typescript
interface EventMap {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "cart:add": { productId: string; quantity: number };
  "cart:remove": { productId: string };
}

type EventCallback<T> = (payload: T) => void;

class TypedEmitter<Events extends Record<string, any>> {
  private listeners = new Map<string, Set<Function>>();

  on<E extends keyof Events & string>(
    event: E,
    callback: EventCallback<Events[E]>
  ): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(callback);
  }

  emit<E extends keyof Events & string>(
    event: E,
    payload: Events[E]
  ): void {
    this.listeners.get(event)?.forEach(cb => cb(payload));
  }
}

const emitter = new TypedEmitter<EventMap>();

// Volle Typ-Sicherheit
emitter.on("user:login", (payload) => {
  console.log(payload.userId);     // OK -- string
  console.log(payload.timestamp);  // OK -- number
});

emitter.emit("cart:add", {
  productId: "abc",
  quantity: 2,  // OK
});

// emitter.emit("cart:add", { productId: "abc" });
// Fehler: Property 'quantity' is missing

// emitter.on("invalid:event", () => {});
// Fehler: not assignable to keyof EventMap

Dieses Pattern eliminiert eine gesamte Fehlerkategorie. Kein falsch geschriebener Event-Name, kein fehlendes Payload-Feld. Alles wird zur Compile-Zeit geprueft — zur Laufzeit ist es ein normaler Event Emitter ohne Overhead.

Wann Typ-Level-Programmierung zu weit geht

Die Versuchung ist real: Wenn das Typsystem eine Programmiersprache ist, warum nicht alles damit lösen? JSON-Parser im Typsystem? Gibt es. SQL-Parser? Auch. TypeScript-Interpreter, der im Typsystem läuft? Ja, auch das existiert.

Das Problem ist nicht, dass es nicht funktioniert. Das Problem ist Wartbarkeit.

Konkrete Warnsignale, dass man zu weit gegangen ist:

Typ-Fehler werden unlesbar. Wenn TypeScript einen Fehler meldet und die Fehlermeldung selbst ein verschachtelter Conditional Type ist, werden Entwickler den Fehler ignorieren statt ihn zu verstehen.

Compile-Zeiten steigen spürbar. Rekursive Conditional Types können die Typ-Pruefung exponentiell verlangsamen. TypeScript hat ein Rekursionslimit (standardmaessig 50 Ebenen), aber schon darunter kann die IDE merklich langsamer werden.

Die Typen ändern sich oefter als der Runtime-Code. Wenn jede Änderung am Code eine Anpassung der komplexen Typen erfordert, ist die Typ-Architektur zu rigide.

Die goldene Mitte liegt in Utility Types, die sich wie natürliche Spracherweiterungen anfuehlen. DeepReadonly, Path, EventMap — das sind Patterns, die einmal geschrieben und dann vergessen werden. Sie erzwingen Korrektheit, ohne im Weg zu stehen. Typ-Level-Sudoku, das nur der Autor versteht, gehört nicht in Produktionscode.

Fazit

Conditional Types, infer und Template Literal Types machen TypeScripts Typsystem zu einem Werkzeug, das weit über einfache Typ-Annotationen hinausgeht. Die Fähigkeit, Typen aus anderen Typen abzuleiten, Strings zur Compile-Zeit zu parsen und rekursive Typ-Transformationen durchzufuehren, ermöglicht API-Designs, die gleichzeitig flexibel und type-safe sind.

Der Schluessel liegt in der Dosierung. Die hier gezeigten Patterns — Deep Readonly, Path Types, Typed Event Emitter — lösen reale Probleme und sind wartbar. Sie nutzen die fortgeschrittenen Features des Typsystems, ohne in akademische Spielerei abzudriften. Das ist der Massstab: Typ-Level-Programmierung sollte den Entwickler-Alltag vereinfachen, nicht verkomplizieren.