TypeScript-Muster, die sich in großen Projekten wirklich auszahlen
Das Typsystem von TypeScript ist mächtig genug, um komplexe Domäneninvarianten abzubilden — aber diese Stärke hat ihren Preis: Typen können zur Wartungslast werden, wenn die Komplexität keinen echten Nutzen bringt. Nach der Arbeit an mehreren mittelgroßen bis großen Enterprise-Codebases sind das die Muster, die zuverlässig Mehrwert liefern, ohne zum Hindernis zu werden.
Diskriminierte Unions für Zustandsmaschinen
Wenn ein Wert einen von mehreren klar unterschiedlichen Zuständen annehmen kann, sollte man das explizit modellieren — statt einer Kombination optionaler Felder:
// Vermeiden: mehrdeutige optionale Felder
interface FetchState {
loading: boolean;
data?: User;
error?: string;
}
// Besser: diskriminierte Union
type FetchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; message: string };
Der Compiler prüft nun alle Fälle vollständig:
function renderState(state: FetchState) {
switch (state.status) {
case 'idle': return 'Nicht gestartet';
case 'loading': return 'Lädt...';
case 'success': return state.data.name; // data hier garantiert nicht null
case 'error': return `Fehler: ${state.message}`;
}
// TypeScript meldet einen Fehler, wenn ein neuer Fall nicht behandelt wird
}
Das zahlt sich vor allem in langlebigen Codebases aus, in denen im Laufe der Zeit neue Fälle hinzukommen — der Compiler markiert jede Aufrufstelle, die aktualisiert werden muss.
Branded Types für Wertobjekte
Primitive Obsession ist ein echtes Problem in der Geschäftslogik. Wer string sowohl für E-Mail-Adressen als auch für Nutzernamen verwendet, kann nicht verhindern, dass der eine dort übergeben wird, wo der andere erwartet wird:
// Das lässt sich nicht verhindern:
function sendWelcomeEmail(email: string, userId: string) { ... }
sendWelcomeEmail(userId, email); // vertauscht — kein Fehler
// Branded Types beheben das:
type Email = string & { readonly _brand: 'Email' };
type UserId = string & { readonly _brand: 'UserId' };
function createEmail(raw: string): Email {
if (!raw.includes('@')) throw new Error('Ungültige E-Mail');
return raw as Email;
}
function sendWelcomeEmail(email: Email, userId: UserId) { ... }
// sendWelcomeEmail(userId, email); // TypeScript-Fehler
Der Branded Type verursacht keinerlei Laufzeitkosten — er ist ausschließlich eine Einschränkung zur Compile-Zeit.
Template-Literal-Typen für Event-Systeme
Beim Aufbau ereignisgesteuerter Architekturen ermöglichen Template-Literal-Typen typsichere Event-Namen:
type EntityEvent<T extends string, A extends string> = `${T}:${A}`;
type UserEvent = EntityEvent<'user', 'created' | 'updated' | 'deleted'>;
// = 'user:created' | 'user:updated' | 'user:deleted'
type OrderEvent = EntityEvent<'order', 'placed' | 'fulfilled' | 'cancelled'>;
type DomainEvent = UserEvent | OrderEvent;
interface EventBus {
on<E extends DomainEvent>(event: E, handler: (payload: EventPayload<E>) => void): void;
emit<E extends DomainEvent>(event: E, payload: EventPayload<E>): void;
}
Besonders nützlich in Microservice- oder nachrichtengesteuerten Systemen, bei denen Event-Namen sowohl menschenlesbare Strings als auch statisch verifizierte Bezeichner sein müssen.
Infer für Typ-Extraktions-Utilities
Das infer-Schlüsselwort erschließt leistungsstarke Utility-Typen, die Typdefinitionen DRY halten:
// Den aufgelösten Werttyp aus einem Promise extrahieren
type Awaited<T> = T extends Promise<infer R> ? R : T;
// Den Element-Typ aus einem Array extrahieren
type ElementOf<T extends readonly unknown[]> = T extends readonly (infer E)[] ? E : never;
// Praktisches Beispiel: Config-Typ vom Builder ableiten
const dbConfig = {
host: 'localhost',
port: 5432,
database: 'verixon',
} as const;
type DbConfig = typeof dbConfig;
// { readonly host: "localhost"; readonly port: 5432; readonly database: "verixon" }
Der satisfies-Operator für validierte Literale
Der satisfies-Operator (TypeScript 4.9+) ermöglicht die Validierung eines Wertes gegen einen Typ, während der spezifischste inferierte Typ erhalten bleibt:
type Route = {
path: string;
component: string;
auth: boolean;
};
const routes = {
home: { path: '/', component: 'HomeComponent', auth: false },
profile: { path: '/me', component: 'ProfileComponent', auth: true },
admin: { path: '/admin', component: 'AdminComponent', auth: true },
} satisfies Record<string, Route>;
// routes.home.path ist als "/" (Literal) typisiert, nicht als string
// TypeScript meldet einen Fehler, wenn einem Route ein Pflichtfeld fehlt
Besonders nützlich für Route-Konfigurationen, Theme-Tokens und überall dort, wo man gleichzeitig Validierung und präzise Inferenz möchte.
Was man vermeiden sollte
Einige Muster, die clever wirken, aber Wartungsaufwand erzeugen:
- Tief verschachtelte Conditional Types — bei mehr als 2–3 Ebenen lieber in benannte Zwischentypen aufteilen
anyübermäßig einsetzen — auch eine einzige "vorübergehende" Verwendung bleibt meist bestehen und erodiert umliegende Typen- Phantom-Typ-Parameter, die zur Laufzeit unbenutzt sind — in kleinen Dosen akzeptabel, in öffentlichen APIs verwirrend
- Typ-Assertions (
as) in der Geschäftslogik — ein Hinweis darauf, dass Laufzeitwerte und Typen auseinanderdriften
Das Ziel ist nicht maximale Typausdruckskraft. Es geht darum, echte Bugs frühzeitig zu erkennen und Refactoring sicher zu machen. An diesem Maßstab sollte man die Typkomplexität messen.
Gedanken dazu? Einfach direkt schreiben.
Artikel diskutieren