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.
// 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.
// 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.
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.
// 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:
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 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:
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.