Auswahl von TypeScript-Mustern nach der Komplexität, die sie beseitigen.
Eine Übersicht über klassische Designmuster und TypeScript-Techniken auf Typenebene, mit klaren Anleitungen dazu, wann jedes Muster sinnvoll ist und wann einfacher Code die bessere Wahl ist.
Die meisten Teams verwenden TypeScript für Autocomplete-Funktionen und die Erkennung von Tippfehlern, bevor sie seinen wahren Wert entdecken: Es ermöglicht es, Designentscheidungen zu kodieren, sodass der Compiler sie durchsetzt. Diese Anleitung führt durch die klassischen objektorientierten Muster sowie die am wichtigstenen auf Typenebene liegenden Techniken in großen Frontend- und Full-Stack-Codebasen und zeigt, wie man entscheidet, wann sich ein Muster lohnt.
Betrachten Sie das Typensystem als etwas, das architektonische Fragen beantworten kann:
- Ist diese Kombination von Zustandswerten tatsächlich möglich?
- Könnte diese API eine Struktur zurückgeben, die der Rest des Codes nicht erwartet?
- Kann ein
ProductIdin eine Funktion gelangen, die eigentlich einenUserIderwartet? - Kann einer Komponente Eigenschaften gegeben werden, die miteinander im Widerspruch stehen?
- Falls ein neuer Zustand hinzugefügt wird, muss dann jeder Nutzer damit umgehen?
any zurückzugreifen?Ein Muster ist keine Funktion, die man einfach hinzufügt. Es ist ein Name für eine Lösung, die man erkennt, sobald sich ein wiederkehrendes Problem zeigt.
Aufschlüsselung des Überblicks
Die klassische Einteilung umfasst kreative, strukturelle und verhaltensbasierte Muster; TypeScript fügt eine vierte Gruppe von typbezogenen Techniken hinzu, die auf Generika, Unionen und Sicherheitstools basieren.
TYPESCRIPT PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
CREATIONAL STRUCTURAL BEHAVIORAL
│ │ │
├─ Factory ├─ Adapter ├─ Strategy
├─ Builder ├─ Facade ├─ Observer
├─ Singleton ├─ Decorator ├─ Command
└─ Abstract Factory ├─ Repository └─ State
└─ Composition
+
TYPESCRIPT TYPE PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
Generics Unions Type Safety
│ │ │
├─ Constraints ├─ Discriminated ├─ Type Guards
├─ keyof Unions ├─ Branded Types
├─ typeof ├─ Result Types ├─ Exhaustiveness
├─ infer └─ State Modeling └─ satisfies
└─ Mapped Types
Reale Anwendungen verwenden selten ein einzelnes Muster. Ein typischer Datenfluss in React setzt mehrere davon zusammen, und jede Grenze kann präzise Typen enthalten:
React Component
│
▼
Custom Hook
│
▼
Service
│
▼
Repository
│
▼
API Client
│
▼
Result<T, E>
│
▼
Discriminated Union
Wenn Typen durch diese gesamte Kette fließen, wird TypeScript zu einer Beschreibung davon, wie sich das System verhalten darf.
Fangen Sie beim Problem an, nicht beim Muster
Eine häufige Falle ist das Denken in die falsche Richtung:
"I know Factory Pattern.
Where can I use Factory?"
Das Erwählen eines Musters zuerst führt zu Abstraktionen, die niemand benötigt. Lassen Sie stattdessen das Problem die Entscheidung bestimmen:
What problem do I have?
↓
Where is the complexity?
↓
What is changing frequently?
↓
What should remain stable?
↓
What abstraction reduces that complexity?
↓
Is a known pattern appropriate?
Betrachten Sie einen Zahlungsablauf, bei dem eine Reihe von Überprüfungen bezüglich der Zahlungsmethode hinzugefügt wurde:
if (paymentMethod === "card") {
// ...
}
if (paymentMethod === "paypal") {
// ...
}
if (paymentMethod === "upi") {
// ...
}
Die reflexhafte Reaktion ist oft folgende:
Don’t immediately think:
„Dafür braucht man Strategy.“ Bevor man darauf zurückgreift, fragen Sie sich, ob die verschiedenen Varianten tatsächlich austauschbare Algorithmen hinter einem einzigen Vertrag sind. Wenn dem so ist, eignet sich Strategy. Wenn das eigentliche Problem darin besteht, dass einige Felder nur für bestimmte Methoden sinnvoll sind, könnte eine diskriminierte Union, die ungültige Kombinationen undarstellbar macht, das bessere Werkzeug sein. Den Katalog zu kennen ist einfach; eine Eintragung mit einem bestimmten Szenario abzugleichen, erfordert Fähigkeiten.
Kreationsmuster
Singleton: eine gemeinsame Instanz
Ein Singleton sorgt dafür, dass eine Klasse genau ein Instanz hat. Der private Konstruktor blockiert Aufrufe außerhalb von new, und eine statische Methode erstellt die Instanz nur dann, wenn sie benötigt wird, und speichert sie anschließend im Cache:
class Logger {
private static instance: Logger;
private constructor() {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message: string) {
console.log(message);
}
}
Die Aufrufer fordern das gemeinsam genutzte Objekt anstelle dessen, dass sie selbst eines erstellen:
const logger = Logger.getInstance();
logger.log("Application started");
Jeder Verbraucher verweist letztendlich auf dasselbe Objekt:
Logger
│
getInstance()
│
▼
┌───────────┐
│ Logger │
│ Instance │
└───────────┘
▲ ▲
│ │
Service A Service B
Angemessene Kandidaten sind unter anderem:
- Logging-Systeme
- Analytics-Manager
- Konfigurationshalter
- Einige Verbindungsmanager
- Weitere cross-cutting-Infrastrukturdienste
Das Problem ist, dass ein Singleton im Grunde nur getarnter globaler Zugriff ist: Tests werden schwieriger zu isolieren, Abhängigkeiten verschwinden aus den Signaturen, der Lebenszyklus wird unklar, und gemeinsamer, veränderlicher Zustand ändert sich auf un nachvollziehbare Weise. In einem modernen Frontend wird eine aus einem ES-Modul exportierte Instanz bereits geteilt, und Dependency Injection, React Context oder eine State-Bibliothek bieten denselben Schutz mit sichtbarer Verkabelung.
Fabrik: Verbergen, welche Klasse erstellt wird
Eine Fabrik entzieht dem Verbraucher die Entscheidung, welche konkrete Klasse instanziert werden soll. Vergleichen Sie die direkte Erstellung:
const payment = new StripePayment();
mit der delegierten Erstellung:
const payment = PaymentFactory.create("stripe");
Sowohl die Anbieter als auch die Fabrik implementieren eine Schnittstelle, wobei die Fabrik eine Union von String-Literalen auf die richtige Klasse abbildet. Da der Parametertyp "stripe" | "paypal" ist, führt ein nicht unterstützter Name zum Kompilierfehler:
interface PaymentProvider {
pay(amount: number): Promise<void>;
}
class StripePayment implements PaymentProvider {
async pay(amount: number) {
console.log("Stripe:", amount);
}
}
class PayPalPayment implements PaymentProvider {
async pay(amount: number) {
console.log("PayPal:", amount);
}
}
class PaymentFactory {
static create(
provider: "stripe" | "paypal"
): PaymentProvider {
switch (provider) {
case "stripe":
return new StripePayment();
case "paypal":
return new PayPalPayment();
}
}
}
Visuell gesehen ist die Fabrik ein Fork, der durch den Namen des Anbieters gekennzeichnet wird:
PaymentFactory
│
┌────────────┴────────────┐
│ │
"stripe" "paypal"
│ │
▼ ▼
StripePayment PayPalPayment
Eine Fabrik lohnt sich, wenn:
- die Erstellung echte Logik beinhaltet
- mehrere Implementierungen denselben Vertrag teilen
- Verbraucher keine konkreten Klassen kennen sollten
- Implementierungen unabhängig von den Aufrufern weiterentwickelt werden müssen
Für triviale Erstellungen wie die untenstehende Zeile fügt sie nur eine zusätzliche Ebene hinzu, die durchgearbeitet werden muss:
new User();
Abstrakte Fabrik: Familien verwandter Objekte
Die abstrakte Fabrik erweitert diese Idee auf Gruppen von Objekten, die übereinstimmen müssen. In einem UI-Kit für mehrere Plattformen sollte ein Web-Button niemals mit einem mobilen Modal kombiniert werden, weshalb jede Fabrik eine konsistente Familie erzeugt:
interface Button {
render(): void;
}
interface Modal {
open(): void;
}
interface UIFactory {
createButton(): Button;
createModal(): Modal;
}
UIFactory
│
┌────────┴────────┐
▼ ▼
WebUIFactory MobileUIFactory
│ │
┌────┴────┐ ┌────┴────┐
▼ ▼ ▼ ▼
Button Modal Button Modal
Es ist leistungsstark, doch es ist leicht, übermäßig aufzubauen. In den meisten Frontend-Anwendungen reicht die einfache Komponentenzusammensetzung aus, um das Ziel zu erreichen – ohne viel Formalität.
Builder: kontrollierte, schrittweise Konstruktion
Der Builder ist nützlich, wenn ein Objekt viele optionale Einstellungen hat oder in mehreren Schritten konfiguriert werden muss. Jeder Setter aktualisiert die private Konfiguration und gibt this zurück, wodurch eine Kettung möglich ist:
class RequestBuilder {
private config: RequestInit = {};
setMethod(method: string) {
this.config.method = method;
return this;
}
setHeaders(headers: HeadersInit) {
this.config.headers = headers;
return this;
}
setBody(body: BodyInit) {
this.config.body = body;
return this;
}
build() {
return this.config;
}
}
Das Zusammenstellen einer Anfrage erscheint als explizite Schritte:
const request = new RequestBuilder()
.setMethod("POST")
.setHeaders({
"Content-Type": "application/json"
})
.setBody(JSON.stringify(data))
.build();
Die fließende Syntax ist ein Nebeneffekt, kein Ziel. Es geht darum, komplexe Konstruktionen explizit und an einem Ort zu halten, wo build() beispielsweise auch Validierungen durchführen kann, wie zum Beispiel das Ablehnen eines Anfragekörpers bei einer GET-Anfrage.
Strukturelle Muster
Adapter: ein stabiler Vertrag um eine fremde API herum
Adapter sind eines der am häufigsten verwendeten Muster in Anwendungscode. Angenommen, Ihr Code erwartet diesen internen Vertrag:
interface PaymentGateway {
pay(amount: number): Promise<void>;
}
während ein SDK von Drittanbietern eine anders benannte Methode bereitstellt:
class LegacyPaymentSDK {
makePayment(value: number) {
// third-party implementation
}
}
Ein schmaler Adapter implementiert Ihre Schnittstelle und übersetzt die Aufrufe:
class PaymentAdapter implements PaymentGateway {
constructor(
private readonly sdk: LegacyPaymentSDK
) {}
async pay(amount: number) {
this.sdk.makePayment(amount);
}
}
Application
│
▼
PaymentGateway
▲
│
PaymentAdapter
│
▼
Third-party SDK
Die Anwendung hängt nun von einer von Ihnen kontrollierten Schnittstelle ab:
PaymentGateway
anstatt von jedem dahinterliegenden Anbieter:
Stripe
PayPal
LegacySDK
SomeFutureProvider
Die Unterstützung eines neuen Anbieters bedeutet das Schreiben eines weiteren Adapters.
Fassade: Ein Aufruf für einen mehrschrittigen Workflow
Eine Fassade stellt einen einfachen Eingangspunkt vor einem komplizierten Untersystem bereit. Das Anmelden kann mehrere Dienste betreffen:
Authentication
+
User Service
+
Permissions
+
Notification
+
Analytics
Ohne Fassade orchestriert jedes Komponente, das einen Benutzer anmeldet, die Abfolge selbst:
auth.login();
user.load();
permission.load();
analytics.track();
Eine Fassade übernimmt diese Orchestrierung einmal:
class AppFacade {
async login(username: string, password: string) {
const token = await auth.login(username, password);
const user = await userService.getUser(token);
await permissionService.load(user);
analytics.track("login");
return user;
}
}
und der Komponente reduziert sich auf einen einzigen Aufruf:
await appFacade.login(username, password);
Component
│
▼
AppFacade
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Auth User Permission
Service Service Service
Das ist vorteilhaft, wenn die Reihenfolge der Schritte wichtig ist.
Decorator: Verhalten hinzufügen durch Umhüllen
Ein Decorator fügt Verhalten hinzu, ohne die ursprüngliche Implementierung zu ändern, da Wrapper und umhülltes Objekt dieselbe Schnittstelle teilen. Der Vertrag:
interface Logger {
log(message: string): void;
}
Eine einfache Implementierung:
class ConsoleLogger implements Logger {
log(message: string) {
console.log(message);
}
}
Ein Decorator, der jeden Logger speichert und Nachrichten mit einem Zeitstempel voransetzt:
class TimestampLogger implements Logger {
constructor(
private readonly logger: Logger
) {}
log(message: string) {
this.logger.log(
`[${new Date().toISOString()}] ${message}`
);
}
}
Das Umhüllen ist lediglich eine Konstruktion:
const logger = new TimestampLogger(
new ConsoleLogger()
);
Da jeder Decorator selbst ein Logger ist, stapeln sie sich:
Logger
│
▼
ConsoleLogger
│
▼
TimestampLogger
│
▼
AdditionalDecorator
Dieselbe Idee tritt unter anderen Namen auf:
- Middlewareschlangen
- Wrapper-Funktionen
- React-Höherer-Ordnungskomponenten
- Protokollierung
- Caching-Schichten
- Autorisierungsprüfungen
Verhaltensmuster
Strategie: austauschbare Algorithmen
Durch die Strategiemethode werden verzweigte Geschäftsregeln beseitigt. Nehmen wir einen Kundenstufen-Typ:
type CustomerType =
| "regular"
| "premium"
| "enterprise";
Eine bedingte Implementierung platziert jede Regel in einer Funktion:
function calculateDiscount(
type: CustomerType,
price: number
) {
if (type === "regular") {
return price;
}
if (type === "premium") {
return price * 0.9;
}
return price * 0.8;
}
Mit der Strategiemethode wird jede Regel zu einer Klasse hinter einer gemeinsamen Schnittstelle:
interface DiscountStrategy {
calculate(price: number): number;
}
class RegularDiscount implements DiscountStrategy {
calculate(price: number) {
return price;
}
}
class PremiumDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.9;
}
}
class EnterpriseDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.8;
}
}
Order
│
▼
DiscountStrategy
│
┌───────────┼───────────┐
▼ ▼ ▼
Regular Premium Enterprise
Strategy Strategy Strategy
Das Hinzufügen einer neuen Stufe bedeutet in der Regel das Hinzufügen einer Strategie anstelle der Bearbeitung der bestehenden Logik – das ist in der Praxis das Offen/Schließen-Prinzip. Für drei sehr einfache Regeln reicht eine bedingte Implementierung aus; die Strategiemethode lohnt sich, sobald die Regeln eigene Abhängigkeiten oder Tests haben.
Observer: Eins-zu-viele Benachrichtigungen
Mit dem Observer-Muster kann ein Subjekt beliebig viele Zuhörer benachrichtigen, wenn sich etwas ändert:
Subject
│
┌──────────┼──────────┐
▼ ▼ ▼
Observer A Observer B Observer C
Ein kleiner typisierter Emitter speichert Zuhörer in einem Set und gibt eine Aufräumfunktion zurück, wenn ein Zuhörer registriert wird:
type Listener<T> = (value: T) => void;
class EventEmitter<T> {
private listeners = new Set<Listener<T>>();
subscribe(listener: Listener<T>) {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
emit(value: T) {
this.listeners.forEach(listener => {
listener(value);
});
}
}
const emitter = new EventEmitter<string>();
const unsubscribe = emitter.subscribe(message => {
console.log(message);
});
emitter.emit("Hello");
unsubscribe();
Diese Aufräumfunktion dient der Lebenszyklusverwaltung. Wenn man sie vergisst aufzurufen, entstehen folgende Probleme:
- Speicherverluste durch Zuhörer, die länger existieren als ihre Eigentümer
- Doppelte Verarbeitung, wenn ein Zuhörer zweimal hinzugefügt wird
- Veraltete Schließungen, die nicht mehr aktuelle Werte lesen
- Nebeneffekte, die nach dem Verschwinden einer Komponente ausgelöst werden
In React ist das genau das, was man aus einer useEffect-Callback-Funktion zurückgibt.
Befehl: Aktionen als Objekte
Der Befehl wandelt eine Aktion in ein Objekt mit einer einheitlichen Schnittstelle um:
interface Command {
execute(): void;
}
Konkrete Befehle implementieren dies:
class SaveCommand implements Command {
execute() {
console.log("Saving...");
}
}
class UndoCommand implements Command {
execute() {
console.log("Undo");
}
}
Die Behandlung von Aktionen als Werte ist nützlich, wenn man benötigt:
- eine Historie dessen, was passiert ist
- Umbuchen und Wiederherstellen
Eine echte Rückgängigmachung erfordert in der Regel, dass jede Anweisung sich selbst rückgängig macht, weshalb Produktionsversionen oft eine undo()-Methode neben der execute()-Methode hinzufügen.
User Action
│
▼
Command
│
├── execute()
│
▼
Receiver
Daten- und Kompositionsmuster
Repository: Trennung des Datenzugriffs
Sobald der Datenzugriff komplex wird, stellt ein Repository einen Vertrag zwischen der Geschäftslogik und dem Element her, das die Daten speichert oder abruft:
UI
│
▼
Hook / Controller
│
▼
Service
│
▼
Repository
│
├── REST
├── GraphQL
├── IndexedDB
└── Cache
Der Vertrag beschreibt, was die Anwendung anfordern kann:
interface UserRepository {
getUser(id: string): Promise<User>;
getUsers(): Promise<User[]>;
}
Eine Implementierung kommuniziert mit einer REST-API:
class ApiUserRepository implements UserRepository {
async getUser(id: string) {
const response = await fetch(`/users/${id}`);
return response.json();
}
async getUsers() {
const response = await fetch("/users");
return response.json();
}
}
Der Geschäftscode hängt von der Schnittstelle ab:
UserRepository
nicht vom Transportmechanismus:
fetch()
axios()
graphqlClient()
Dies ist wichtig, wenn sich die Quelle ändert: von REST zu GraphQL, der Hinzufug eines IndexedDB-Caches oder ein in-Memory-Modell für Tests. Beachten Sie, dass response.json() einen untypisierten Wert zurückgibt, weshalb das Repository auch der richtige Ort ist, um Antworten zu validieren.
Komposition statt Vererbung
In der Frontend-Entwicklung ist Komposition wichtiger als jedes auf Vererbung basierende Muster. Anstatt eines Components, das alles beherrscht:
MegaComponent
├── Authentication
├── Table
├── Filters
├── Modal
├── Notifications
├── API calls
└── Business logic
teilen Sie die Verantwortlichkeiten in fokussierte Teile auf:
Dashboard
├── Header
├── Sidebar
├── FilterPanel
├── DataTable
└── NotificationPanel
In React bedeutet das einfach das Nesten von Components:
<Dashboard>
<Header />
<Sidebar />
<MainContent />
</Dashboard>
Jeder Teil kann für sich allein verstanden, getestet und ersetzt werden. Weitere Informationen zur Aufteilung überdimensionierter Components finden Sie unter der Behebung von React-Prop-Überlastung mit Komposition und Slots.
Modellierung des Zustands mit dem Typensystem
Von nun an ist TypeScript selbst das architektonische Werkzeug.
Diskriminierte Unionen für den Anfragezustand
Eine Anfrage durchläuft Phasen, die unterschiedliche Daten enthalten. Eine diskriminierte Union verleiht jeder Phase ihre eigene Struktur, die durch ein literales status-Feld miteinander verbunden ist:
type RequestState =
| {
status: "idle";
}
| {
status: "loading";
}
| {
status: "success";
data: User[];
}
| {
status: "error";
error: string;
};
Durch die Verwendung des Diskriminanten wird der Typ in jedem Zweig eingegrenzt:
function render(state: RequestState) {
switch (state.status) {
case "idle":
return "Nothing started";
case "loading":
return "Loading...";
case "success":
return state.data;
case "error":
return state.error;
}
}
Der Compiler überwacht, welche Felder nach jeder Überprüfung vorhanden sind:
status = "success"
↓
data exists
status = "error"
↓
error exists
Vergleichen Sie das gängige Modell aus Booleschen Werten und Optionals:
interface State {
loading: boolean;
data?: User[];
error?: string;
}
Nichts hindert daran, einen Zustand zu beschreiben, der niemals auftreten sollte:
{
loading: true,
data: [...],
error: "Something failed"
}
Die Union macht solche Kombinationen sehr schwer ausdrückbar. Modellieren Sie stattdessen die gültigen Zustände, anstatt optionale Eigenschaften überall zu verteilen und darauf zu vertrauen, dass jeder sie richtig kombiniert.
Ergebnistypen für expliziten Fehlerfall
Viele Operationen haben genau zwei Ergebnisse:
Success
OR
Failure
Ein Result-Typ beschreibt beide Ergebnisse mithilfe eines booleschen Diskriminanten:
type Result<T, E> =
| {
success: true;
data: T;
}
| {
success: false;
error: E;
};
Eine Funktion, die ihn zurückgibt:
function getUser(): Result<User, string> {
return {
success: true,
data: user
};
}
Der Aufrufer muss zuerst success überprüfen, bevor er auf data oder error zugreift:
const result = getUser();
if (result.success) {
console.log(result.data);
} else {
console.error(result.error);
}
Service
│
▼
Result<T, E>
/ \
/ \
▼ ▼
Success Failure
│ │
data error
Das passt zu erwarteten Geschäftsfehlern wie „E-Mail bereits registriert“. Ausnahmen eignen sich weiterhin für echte Fehler und Infrastrukturprobleme; der Result-Typ macht lediglich die erwarteten Fehler in der Signatur sichtbar.
Generics und typbezogene Werkzeuge
Generics bewahren die Verbindung zwischen Eingabe und Ausgabe
Die Verwendung von any führt dazu, dass Informationen verloren gehen:
function identity(value: any): any {
return value;
}
Ein Typparameter bewahrt diese Informationen auf:
function identity<T>(value: T): T {
return value;
}
Das ermittelte Ergebnis folgt dem Argument:
const a = identity("hello");
// string
const b = identity(100);
// number
Die Beziehung zwischen Eingabe und Ausgabe bleibt bestehen:
Input T
│
▼
Function<T>
│
▼
Output T
Generics machen gemeinsame Verträge zudem wiederverwendbar. Ein einziger Antwortumschlag:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
beschreibt viele Payloads:
type UserResponse =
ApiResponse<User>;
type ProductResponse =
ApiResponse<Product>;
Einschränkungen: Formvorgabe erforderlich
Dies lässt sich nicht kompilieren:
function getId<T>(item: T) {
return item.id;
}
weil nichts TypeScript mitteilt, dass T eine id hat. Eine Einschränkung bietet diese Garantie:
function getId<T extends { id: string }>(
item: T
) {
return item.id;
}
Jedes Objekt mit einer String-id wird akzeptiert, einschließlich zusätzlicher Felder:
getId({
id: "123",
name: "Hareesh"
});
Die Einschränkung lässt sich wie folgt lesen:
T can be anything
BUT
T must have id: string
keyof und indizierter Zugriff
Gegeben ist eine Schnittstelle:
interface User {
id: string;
name: string;
age: number;
}
keyof ergibt die Vereinigung der Eigennamen dieser Schnittstelle:
type UserKey = keyof User;
"id" | "name" | "age"
Die Kombination von keyof mit einem zweiten Typparameter sowie dem indizierten Zugriffstyp T[K] ergibt einen Zugriffsfunktor, dessen Rückgabetyp dem Schlüssel entspricht:
function getProperty<T, K extends keyof T>(
object: T,
key: K
): T[K] {
return object[key];
}
const user = {
id: "1",
name: "Hareesh",
age: 30
};
getProperty(user, "name");
Ein echter Schlüssel lässt sich kompilieren:
getProperty(user, "name");
Ein fehlender Schlüssel führt zu einem Kompilierfehler:
getProperty(user, "salary");
Die Stärke ergibt sich aus der Zusammenarbeit von drei Werkzeugen:
Generics
+
keyof
+
Indexed Access
Abgebildete Typen: Transformation eines bestehenden Typs
Abgebildete Typen durchlaufen die Schlüssel eines Typs, um einen neuen Typ zu erstellen. Ausgehend von:
interface User {
id: string;
name: string;
email: string;
}
kann man eine vollständig optionale Version ableiten:
type OptionalUser = {
[K in keyof User]?: User[K];
};
konzeptionell gleichbedeutend mit dem Schreiben:
{
id?: string;
name?: string;
email?: string;
}
So werden eingebaute Konstrukte wie Partial definiert.
Bedingte Typen: Entscheidungen auf Typebene
Bedingte Typen wählen zwischen zwei Typen auf der Grundlage der Zuweisbarkeit aus:
T extends U ? X : Y
Dieser entpackt die Typen von Array-Elementen und lässt alles andere unberührt:
type Flatten<T> =
T extends Array<infer U>
? U
: T;
type A = Flatten<string[]>;
// string
type B = Flatten<number>;
// number
Zu diesem Zeitpunkt verhält sich das Typsystem wie eine kleine Sprache zur Kompilierzeit, was Zurückhaltung erfordert.
infer: Teil eines Typs extrahieren
In einem bedingten Typ deklariert infer eine Typvariable, die von TypeScript durch Abgleich gefüllt wird. Dadurch wird der eingebaute ReturnType neu implementiert:
type MyReturnType<T> =
T extends (...args: any[]) => infer R
? R
: never;
Kombinieren Sie ihn mit typeof, um einen Typ aus einer vorhandenen Funktion abzuleiten, damit er niemals von der Implementierung abweicht:
function getUser() {
return {
id: "1",
name: "Hareesh"
};
}
type User = MyReturnType<typeof getUser>;
Bibliothekstypdefinitionen sind stark davon abhängig.
Zuerst die eingebauten Hilfstypen
Bevor Sie clevere Hilfsfunktionen schreiben, sollten Sie wissen, was mit der Sprache geliefert wird:
Partial
Required
Readonly
Pick
Omit
Record
Exclude
Extract
NonNullable
ReturnType
Parameters
InstanceType
Awaited
Das Entfernen eines sensiblen Feldes erfolgt beispielsweise in einer einzigen Zeile, anstatt durch eine duplizierte Schnittstelle, die aus dem Gleichgewicht gerät:
interface User {
id: string;
name: string;
email: string;
password: string;
}
type PublicUser =
Omit<User, "password">;
Der Leitfaden zu TypeScript’s eingebauten Hilfs-Typen behandelt jeden dieser Punkte ausführlich.
Record mit einer geschlossenen Menge an Schlüsseln
Record eignet sich für Abfragen und Konfiguration, insbesondere wenn die Schlüssel aus einer literalen Union stammen:
type Permission =
"read" |
"write" |
"delete";
type PermissionMap =
Record<Permission, boolean>;
const permissions: PermissionMap = {
read: true,
write: false,
delete: false
};
Wenn eine Berechtigung weggelassen wird, meldet TypeScript den fehlenden Schlüssel. Ein breiter Schlüsseltyp verliert diese Garantie:
const permissions: Record<string, boolean>
denn Record<string, boolean> akzeptiert praktisch jeden String-Schlüssel, wodurch Auslassungen und Tippfehler unentdeckt bleiben.
Sicherheitsmuster für Identifikatoren und unzuverlässige Daten
Besondere Typen für Domänenidentifikatoren
Zwei Identifikatoren können beide Strings sein, aber unterschiedliche Bedeutungen haben:
const userId: string;
const productId: string;
Strukturell kann TypeScript sie nicht voneinander unterscheiden. Die Überlappung von string mit einer Phantom-Eigenschaft erzeugt unterschiedliche Typen:
type UserId =
string & {
readonly __brand: "UserId";
};
type ProductId =
string & {
readonly __brand: "ProductId";
};
Funktionen können anschließend den richtigen Typ von ID verlangen:
function getUser(id: UserId) {}
function getProduct(id: ProductId) {}
Das Übergeben eines ProductId, wo eigentlich ein UserId erwartet wird, führt zu einem Kompilierfehler. Die Marke existiert zur Laufzeit nie; markenspezifische Werte werden durch einen kleinen Konstruktor oder eine Validierungsfunktion erstellt, die an einer Stelle typumwandelt. Konzeptionell:
string
│
├── UserId
├── ProductId
├── OrderId
└── TransactionId
Das lohnt sich in großen Systemen, in denen Dutzende von Identifikatoren denselben primitiven Typ teilen.
Typschutzmechanismen für unknown-Eingaben
Daten aus dem Netzwerk, der Speicherung oder vom Benutzer sollten als unknown eintreffen:
const data: unknown = await response.json();
Eine Typumwandlung ist der verlockende Abkürzungsweg:
const user = data as User;
Beschränken Sie den Wert stattdessen mit einem vom Benutzer definierten Type Guard; seine Rückgabetypangabe value is User teilt dem Compiler mit, was ein true-Ergebnis beweist:
function isUser(
value: unknown
): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}
if (isUser(data)) {
console.log(data.name);
}
Vergessen Sie nicht, dass Typen zur Laufzeit ausgeblendet werden. Eine solche Schnittstelle:
interface User {
id: string;
}
überprüft nichts von dem, was die API sendet. Der obige Guard prüft lediglich, ob Schlüssel vorhanden sind, nicht jedoch ihre Typen; daher sollte bei unzuverlässigen Eingaben TypeScript mit einem Laufzeit-Schema-Validator verwendet werden.
Ausführliche Überprüfung mit never
Eine der wirksamsten Kombinationen in der Sprache:
Discriminated Union
+
never
+
switch
Nehmen Sie eine Status-Union:
type Status =
| "loading"
| "success"
| "error";
und einen Hilfsfunktion, der nur never akzeptiert:
function assertNever(
value: never
): never {
throw new Error(
`Unexpected value: ${value}`
);
}
Sobald alle Mitglieder behandelt wurden, sieht der Standardzweig den Typ never, wodurch der Aufruf kompiliert:
function render(status: Status) {
switch (status) {
case "loading":
return "Loading";
case "success":
return "Success";
case "error":
return "Error";
default:
return assertNever(status);
}
}
Nun erweitert ein Teammitglied die Union:
Now imagine someone adds:"cancelled" to Status.
Der Standardfall erhält „cancelled“, was nicht auf „never“ zugewiesen werden kann, wodurch der Build fehlschlägt, bis dieser Fall behandelt wird. Der Compiler fungiert dabei wie ein Entwurfsprüfer, der sich an jeden Verbraucher erinnert.
Template-Literal-Typen
TypeScript kann String-Literal-Typen aus anderen Typen zusammensetzen:
type Entity =
"user" |
"order" |
"product";
type Event =
`${Entity}:created` |
`${Entity}:updated` |
`${Entity}:deleted`;
Die resultierende Union enthält jede mögliche Kombination:
user:created
user:updated
user:deleted
order:created
order:updated
order:deleted
product:created
product:updated
product:deleted
Praktische Anwendungen sind unter anderem:
- Ereignisnamen
- Analytics-Ereigniskennungen
- Berechtigungsstrings
- Routenmuster
- Feature-Flag-Namen
- Design-System-Token
as const, wenn die Werte die Quelle der Wahrheit sind
Ein Array-Literal aus Strings wird erweitert:
const roles = [
"admin",
"editor",
"viewer"
];
Ihr Typ ist string[]. Das Hinzufügen von as const erzeugt eine nur zum Lesen zugängliche Tupel aus Literalen:
const roles = [
"admin",
"editor",
"viewer"
] as const;
Daraus ergibt sich direkt ein Union-Typ:
type Role =
typeof roles[number];
becomes:
"admin" |
"editor" |
"viewer"
Definieren Sie die Werte einmal und pflegen Sie niemals manuell eine parallele Union.
satisfies für überprüfte Konfigurationen
satisfies überprüft einen Ausdruck gegenüber einem Typ, ohne ihn auf diesen Typ zu erweitern:
type Config = {
retries: number;
environment:
| "development"
| "production";
};
const config = {
retries: 3,
environment: "production"
} satisfies Config;
Das Objekt wird gegenüber Config überprüft, doch config.environment behält den Literaltyp "production" bei, was eine einfache Annotation verlieren würde. Es eignet sich für:
- Routenkonfigurationen
- Feature-Flags
- Design-Tokens
- Statische Einstellungsobjekte
- Berechtigungsprofile
Abhängigkeitsinjektion
Die Erstellung von Abhängigkeiten innerhalb einer Klasse verbindet sie damit:
class UserService {
private api = new ApiClient();
}
Die Übernahme dieser Abhängigkeiten über den Konstruktor hält die Klasse agnostisch:
class UserService {
constructor(
private readonly api: ApiClient
) {}
}
In der Produktion wird der echte Client übergeben:
const service =
new UserService(apiClient);
und in Tests wird ein Mock verwendet:
const service =
new UserService(mockApiClient);
UserService
▲
│
Dependency
│
┌────────┴────────┐
▼ ▼
ApiClient MockApiClient
Production Testing
Es handelt sich dabei um einen der kostengünstigsten Wege zu testbarem Code, und Konstruktorparameter sind alles, was dafür benötigt wird.
Ähnliche Muster voneinander unterscheiden
Zustandsmuster oder diskriminierte Union?
Angenommen, es gibt ein Lebenszyklus-Muster für Bestellungen:
Draft
Paid
Shipped
Cancelled
Eine diskriminierte Union könnte alles sein, was benötigt wird:
type Order =
| { status: "draft" }
| { status: "paid" }
| { status: "shipped" }
| { status: "cancelled" };
Aber wenn jeder Zustand erhebliches Verhalten mit sich bringt:
Draft
├── edit()
├── submit()
Paid
├── refund()
├── ship()
Shipped
├── track()
└── deliver()
könnte ein Zustandsmuster, bei dem jedes Zustandsobjekt die zulässigen Operationen implementiert, besser passen. Entscheiden Sie anhand der Komplexität jedes Zustands und nicht anhand der Terminologie.
Strategie oder Zustand?
Ein häufiges Thema in Interviews. Mit Strategy wählt der Kunde den Algorithmus aus:
Order
│
▼
Strategy
├── CreditCard
├── PayPal
└── UPI
Mit State ändert das Objekt sein eigenes Verhalten im Laufe seines Lebenszyklus:
Order
│
├── Draft
├── Paid
└── Shipped
Strategy bestimmt, wie eine Aufgabe ausgeführt wird; State bestimmt, was ein Objekt in seiner aktuellen Phase tut.
Fabrik oder Strategy?
Eine Fabrik beantwortet die Frage "welches Objekt soll erstellt werden?":
PaymentFactory.create("stripe");
Strategy beantwortet die Frage "welches Verhalten soll ausgeführt werden?":
new Order(discountStrategy);
Sie ergänzen sich natürlicherweise – eine Fabrik erzeugt die Strategy, die die Arbeit ausführt:
Factory
↓
creates
↓
Strategy
↓
executes behavior
Anwendung der Muster in React
Variant-Props anstelle von booleschen Flags
Unabhängige Boolesche Werte ermöglichen Unsinn wie einen Button, der gleichzeitig primär und gefährlich ist:
interface ButtonProps {
primary?: boolean;
danger?: boolean;
loading?: boolean;
}
Eine Vereinigung von Varianten lässt jedes seine eigenen Anforderungen deklarieren:
type ButtonProps =
| {
variant: "primary";
loading?: boolean;
}
| {
variant: "danger";
confirmationRequired: boolean;
};
Die Gefahrenvariante muss nun confirmationRequired angeben.
Komponenten-APIs, die Widersprüche ablehnen
Eine Komponente, die entweder einen Link oder eine Schaltfläche mit optionalen href und onClick darstellt, würde sowohl beides als auch keines zulassen. Der folgende Auszug zeigt die lockere Version, die differenzierte Alternative sowie eine gültige Verwendung:
{
href?: string;
onClick?: () => void;
}
use:
type ActionProps =
| {
type: "link";
href: string;
}
| {
type: "button";
onClick: () => void;
};
Now:
<Action
type="link"
href="/users"
/>
Eine Link-Variante, bei der anstelle eines href ein Klick-Handler bereitgestellt wird, wird abgelehnt:
<Action
type="link"
onClick={...}
/>
Die unterstützten Modi, klar formuliert:
Link
Button
TypeScript ist nun Teil der Komponentenarchitektur und nicht mehr nur Dokumentation, die veraltet wird.
Ein typsicherer Event-Bus
Beginnen Sie mit einem Mapping von Ereignisnamen zu Payload-Typen:
type Events = {
"user:created": User;
"user:deleted": UserId;
"order:created": Order;
};
Ein Bus-Generik über dieses Mapping verknüpft jeden Namen mit seinem Payload mithilfe von keyof und T[K]:
class EventBus<T extends Record<string, unknown>> {
on<K extends keyof T>(
event: K,
handler: (data: T[K]) => void
) {
// implementation
}
emit<K extends keyof T>(
event: K,
data: T[K]
) {
// implementation
}
}
Der korrekte Payload kompiliert:
bus.emit("user:created", user);
Der falsche nicht:
bus.emit("user:created", order);
Mehrere Tools kommen hier zum Einsatz:
Generics
+
keyof
+
Indexed Access
+
Mapped Type thinking
Typen in der gesamten API-Schicht
Ein ausgereifter Frontend-Teil strukturiert den Datenzugriff oft wie folgt:
Component
↓
Hook
↓
Service
↓
Repository
↓
API Client
↓
HTTP
mit Typen, die in jedem Schritt übertragen werden. Ein Repository kann markierte IDs entgegennehmen und ein Result zurückgeben:
type ApiResponse<T> = {
data: T;
status: number;
};
interface UserRepository {
getUser(id: UserId):
Promise<Result<User, ApiError>>;
}
Die Komponente muss sich nicht mehr in jeder Schicht damit beschäftigen:
any
Die Signaturen selbst geben an, was erfolgreich sein kann, was fehlschlagen kann und mit welchen Typen.
Anti-Muster, die vermieden werden sollten
any überall
function process(data: any) {}
Ein any-Parameter deaktiviert die Überprüfung für alles, was er berührt. Besser ist:
function process(data: unknown) {}
und vor der Verwendung einengen.
Aussagen als Validierung
const user =
response.data as User;
Ein as-Umwandlungstest überprüft nichts; er bittet den Compiler lediglich, Ihnen zu vertrauen. Validieren Sie unzuverlässige Daten zur Laufzeit.
Generics um ihrer selbst willen
Vermeiden Sie Signaturen wie:
function transform<
T,
U,
V,
R
>(...) {}
es sei denn, jeder Typparameter drückt eine echte Beziehung aus. Ein einmal verwendeter Typparameter ist in der Regel überflüssig.
Riesige Unionen
Eine diskriminierte Union mit hundert Mitgliedern ist schwer zu durchforsten und weiterzuentwickeln. Der Anwendungsfall könnte eine andere Abstraktion benötigen, wie z. B. verschachtelte Unionen oder die Verlegung der Variation in die Daten.
Klugheit auf Typebene
Wenn ein Typ schwieriger zu verstehen ist als die von ihm beschriebene Geschäftslogik, treten Sie einen Schritt zurück:
Type complexity
│
▼
Developer complexity
│
▼
Maintenance cost
Typsicherheit hat ihren Preis. Streben Sie die nützlichste Sicherheit pro Komplexitätsgrad an, nicht die ausgefeiltesten Typen.
Eine Entscheidungstree für die Auswahl
Dieser Baum ordnet gängige Anforderungen den entsprechenden Musterkandidaten zu:
PROBLEM
│
├── Need to create objects?
│ ├── Simple creation → Constructor
│ ├── Complex creation → Builder
│ ├── Multiple implementations → Factory
│ └── Families of objects → Abstract Factory
│
├── Need to integrate another system?
│ └── Adapter
│
├── Complex subsystem?
│ └── Facade
│
├── Add behavior without modifying object?
│ └── Decorator
│
├── Multiple interchangeable algorithms?
│ └── Strategy
│
├── Subscribers react to changes?
│ └── Observer
│
├── Need actions/history/undo?
│ └── Command
│
├── Data-access abstraction?
│ └── Repository
│
├── Complex state lifecycle?
│ └── State / Discriminated Union
│
└── Type-level problem?
├── Reuse → Generics
├── Transform → Mapped Types
├── Decision → Conditional Types
├── Extract → infer
├── Property safety → keyof
├── Literal safety → as const
├── Contract validation → satisfies
└── Domain safety → Branded Types
Betrachten Sie ihn als Ausgangspunkt für Diskussionen; das Prinzip „einfach anfangen“ gilt weiterhin bei jedem Knoten.
Was man zuerst lernen sollte
Für Frontend-Entwickler, die sich auf Führungspositionen vorbereiten, ist das Auswendiglernen aller Gang-of-Four-Muster eine schlechte Zeitnutzung. Eine praktische Reihenfolge:
Die Grundlagen:
Discriminated Unions
Generics
Type Guards
keyof
Mapped Types
Utility Types
Composition
Strategy
Repository
Factory
Sehr empfohlen als Nächstes:
Result Type
Branded Types
Conditional Types
infer
Exhaustive Checking
Dependency Injection
Adapter
Facade
Observer
Decorator
Conceptuell verständlich zu machen lohnt sich:
Builder
Command
State
Abstract Factory
Singleton
Die tägliche Frontend-Arbeit stützt sich weitaus mehr auf diese Kombination:
Generics
+
Discriminated Unions
+
Composition
+
Strategy
+
Repository
als auf irgendeine aus dem Lehrbuch bekannte Abstrakte Fabrik.
Wie sich das Urteilsvermögen mit Erfahrung verändert
Zu Beginn fragen Ingenieure: „Welches Muster soll ich hier verwenden?“ Mit Erfahrung in großen Systemen wird die Frage zu: „Welche minimale Abstraktion löst dieses Problem, ohne das System unverständlicher zu machen?“ Der prozessorientierte Ansatz mit den Mustern sieht so aus:
Problem
↓
Pattern
↓
More classes
↓
More abstractions
Der problemorientierte Ansatz sieht so aus:
Problem
↓
Understand volatility
↓
Identify boundary
↓
Start simple
↓
Introduce abstraction only where repetition/change justifies it
Gute Muster entstehen, wenn die Anforderungen der Architektur klar werden; sie werden nicht im Voraus aufgezwungen.
Interviewfragen, die echtes Verständnis prüfen
„Was ist das Factory-Muster?“ prüft das Gedächtnis. Diese Fragen testen das Urteilsvermögen.
Architektur:
- Angesichts einer 2.000 Zeilen langen React-Komponente: Wie wählt man aus, welche Abstraktion eingeführt werden soll?
- Wann lehnt man ein scheinbar passendes Muster ab?
- Wie erkennt man, dass eine Abstraktion vorzeitig ist?
Grundlagen von TypeScript:
- Wie würde man eine Anfrage mit Zuständen wie Laden, Erfolg, Fehler und Wiederholung modellieren?
- Wie verhindert man ungültige Kombinationen von React-Props?
- Inwiefern unterscheiden sich
unknown,anyundnever? - Wann sollte man
typestattinterfacewählen und umgekehrt? - Wie interagiert
keyofmit Generics?
Fortgeschrittener TypeScript:
- Was ist ein konkreter Anwendungsfall für bedingte Typen?
- Was bewirkt
infer? - Was ist ein mappierter Typ?
- Was sind distributive bedingte Typen?
- Wann lohnen sich branded Types?
- Welches Problem löst
satisfies? - Wie verändert
as constdie Inferenz?
Reale Entwurfsaufgaben:
- Entwerfen Sie einen typsicheren Event-Bus.
- Entwerfen Sie einen typsicheren API-Client.
- Entwerfen Sie ein Berechtigungssystem unter Verwendung von TypeScript.
- Entwerfen Sie eine Zahlungsabstraktion, die Stripe, PayPal und einen dritten Anbieter unterstützt.
- Wie würden Sie eine Repository-Schicht zu einer bestehenden React-Anwendung hinzufügen, ohne diese neu schreiben zu müssen?
- Wie würden Sie WebSocket-Ereignisse mit unterschiedlichen Payloads typisieren?
- Wie würden Sie verhindern, dass ein
ProductIdübergeben wird, wo eigentlich einUserIderforderlich ist?
Eine aufschlussreiche Frage: „Beschreiben Sie einen Moment, in dem Sie absichtlich darauf verzichtet haben, ein Designmuster zu verwenden.“ Eine gute Antwort erläutert den Kompromiss: Bei einer einzigen Implementierung ohne wahrscheinliche Änderungen, gegen die man vorgehen müsste, hätte die Indirektheit die Komplexität nicht verringert, sodass der Code einfach blieb, bis ein zweiter Anwendungsfall dies rechtfertigte.
Ein letztes mentales Modell
Beginnen Sie mit dem Geschäftsproblem, finden Sie heraus, wo die Komplexität liegt, trennen Sie verhaltensbezogene Änderungen von strukturellen Änderungen und lassen Sie anschließend die Typsicherheit das Ergebnis straffen.
BUSINESS PROBLEM
│
▼
Identify Complexity
│
▼
What is likely to change?
│
┌──────────┴──────────┐
▼ ▼
Behavior Structure
│ │
Strategy/State Adapter/Facade
│ │
└──────────┬──────────┘
▼
Type Safety
│
┌───────────────┼────────────────┐
▼ ▼ ▼
Generics Unions Utilities
│ │ │
▼ ▼ ▼
keyof Result Type Mapped Types
infer State Model Conditional
satisfies Exhaustive Record
│ │ │
└───────────────┼────────────────┘
▼
SIMPLEER CODE
Wichtige Erkenntnisse
Muster funktionieren am besten als gemeinsames Vokabular: „Das ist ein Adapter“, „Diese Verhaltensweisen sind austauschbar“, „Diese Zustände gehören zu einer Union“, „Markieren Sie diese IDs“ – und am wertvollsten: „Diese Abstraktion hat ihre Komplexität noch nicht verdient.“ Gutes TypeScript wird danach beurteilt, ob das System diese Eigenschaften aufweist, und nicht danach, wie clever seine Typen sind:
Invalid states
↓
become difficult to represent
Changing implementations
↓
don't break consumers
Business rules
↓
are visible in the types
Shared behavior
↓
is reusable without duplication
Complexity
↓
is isolated behind clear boundaries
- Wählen Sie Muster anhand der Komplexität aus, die sie beseitigen, und nicht aufgrund von Vertrautheit.
- Ziehen Sie Unionen,
Result-Typen sowie ausführliche Überprüfungen vor Optionalfeldern und Hoffnung. - Validieren Sie Daten an den Grenzen der Laufzeit; allein Typen schützen Sie dort nicht.
- Wählen Sie die einfachste Abstraktion, die den tatsächlich erwarteten Wandel bewältigt.