TypeScript-Patterns für große Codebasen
Fortgeschrittene TypeScript-Patterns für den Alltag: Discriminated Unions, Branded Types, Builder-Pattern und Utility Types für große Codebasen.

Jenseits der Basistypen
Die wahre Stärke von TypeScript zeigt sich in großen Codebasen, wo Typsicherheit ganze Klassen von Fehlern verhindert. Das sind die Patterns, auf die ich am häufigsten zurückgreife.
Discriminated Unions für Zustandsautomaten
Anstatt optionale Felder und boolesche Flags zu verwenden, solltest du Zustände explizit modellieren:
// ❌ Boolean soup — easy to create invalid states
interface Request {
data?: Response;
error?: Error;
isLoading: boolean;
isError: boolean;
}
// ✅ Discriminated union — each state is explicit
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderUser(state: AsyncState<User>) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return <UserCard user={state.data} />;
case "error":
return <ErrorMessage error={state.error} />;
}
}Der Compiler garantiert, dass du jeden Fall behandelst. Du fügst einen neuen Zustand hinzu? TypeScript zeigt dir jede Stelle, die aktualisiert werden muss.
Branded Types für Domänensicherheit
Primitive Typen verhindern nicht, dass Werte desselben Typs versehentlich vertauscht werden:
// ❌ Both are strings — easy to swap by accident
function transferMoney(fromAccountId: string, toAccountId: string) {}
transferMoney(toId, fromId); // Bug! No compiler error.
// ✅ Branded types — compiler catches the swap
type AccountId = string & { readonly __brand: "AccountId" };
type TransactionId = string & { readonly __brand: "TransactionId" };
function accountId(id: string): AccountId {
return id as AccountId;
}
function transfer(from: AccountId, to: AccountId) {}
transfer(txnId, accountId("123")); // ✅ Compiler error!Ich verwende Branded Types für IDs, Währungen, E-Mail-Adressen und jeden Domänenwert, bei dem eine Verwechslung von Primitiven zu echten Fehlern führen würde.
Das Builder-Pattern für komplexe Objekte
Beim Erstellen von Objekten mit vielen optionalen Feldern bieten Builder eine Fluent API mit Validierung zur Kompilierzeit:
class QueryBuilder<T extends Record<string, unknown>> {
private query: Partial<T> = {};
where<K extends keyof T>(key: K, value: T[K]): this {
this.query[key] = value;
return this;
}
orderBy(field: keyof T, direction: "asc" | "desc" = "asc"): this {
// Store ordering config
return this;
}
limit(n: number): this {
// Store limit
return this;
}
build(): T {
return this.query as T;
}
}
// Usage — fully type-safe
const query = new QueryBuilder<User>()
.where("role", "admin")
.where("active", true)
.orderBy("createdAt", "desc")
.limit(10)
.build();Utility-Types, die du kennen solltest
Die eingebauten Utility-Types von TypeScript sind sehr mächtig. Das sind die, die ich am meisten verwende:
// Extract only the keys whose values match a type
type KeysOfType<T, V> = {
[K in keyof T]: T[K] extends V ? K : never;
}[keyof T];
type User = {
id: number;
name: string;
email: string;
age: number;
};
type StringKeys = KeysOfType<User, string>; // "name" | "email"
// Make specific keys required
type RequireKeys<T, K extends keyof T> = T & Required<Pick<T, K>>;
type Config = {
host?: string;
port?: number;
debug?: boolean;
};
type ProductionConfig = RequireKeys<Config, "host" | "port">;
// { host: string; port: number; debug?: boolean }
// Deep readonly — prevents mutation at any depth
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};satisfies für Const Assertions
Der satisfies-Operator bietet dir das Beste aus beiden Welten — Typprüfung und präzise Typinferenz:
const ROUTES = {
home: "/",
blog: "/blog",
about: "/about",
contact: "/contact",
} as const satisfies Record<string, string>;
// Type is narrowed to the literal values
type Route = (typeof ROUTES)[keyof typeof ROUTES];
// "/" | "/blog" | "/about" | "/contact"
// Typo in the value? Compiler catches it.
// Missing a required key? Compiler catches it.Typsichere Event-Emitter
Kombiniere Generics mit Mapped Types für vollständig typisierte Events:
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"order:created": { orderId: string; total: number };
};
class TypedEmitter<T extends Record<string, unknown>> {
private handlers = new Map<keyof T, Set<(data: never) => void>>();
on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler as (data: never) => void);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
this.handlers.get(event)?.forEach((handler) => handler(data as never));
}
}
const emitter = new TypedEmitter<EventMap>();
emitter.on("user:login", (data) => {
console.log(data.userId); // ✅ Typed
console.log(data.timestamp); // ✅ Typed
});
emitter.emit("order:created", {
orderId: "123",
total: 99.99,
// extra: true — ✅ Compiler error!
});Die wichtigsten Erkenntnisse
- Modelliere Zustände, keine Flags — Discriminated Unions eliminieren unmögliche Zustände
- Branding für Primitive — Lass
stringnicht alles bedeuten - Nutze
satisfies— Erhalte sowohl Typsicherheit als auch literale Typinferenz - Baue Utility-Types — Kleine Typ-Helfer summieren sich zu enormen Sicherheitsgewinnen
- Lass den Compiler für dich arbeiten — Wenn ein Fehler zur Kompilierzeit erkannt werden kann, sollte er das auch werden
TypeScript zeigt sich von seiner besten Seite, wenn du dich auf das Typsystem einlässt, anstatt mit any und Type Assertions dagegen anzukämpfen. Die anfängliche Investition zahlt sich bei jedem Refactoring, jedem Code-Review und jedem Produktions-Deployment aus.


