Factory, Strategy, Observer und Builder mit echten Typgarantien
TypeScript verwandelt altbekannte GoF-Patterns aus der objektorientierten Softwareentwicklung in Werkzeuge mit echten Compile-Zeit-Garantien. Wer Factory, Strategy, Observer und Builder mit diskriminierten Unions, generischen Typen und Exhaustiveness-Checks kombiniert, verhindert ganze Fehlerklassen, die in reinem JavaScript erst zur Laufzeit auffallen, und baut gleichzeitig wartbarere, besser testbare Architekturen für Checkout- und Headless-Commerce-Systeme.
Inhaltsverzeichnis
- 1. Warum klassische GoF-Patterns von TypeScript profitieren
- 2. Discriminated Unions und Exhaustiveness-Checks als Fundament
- 3. Das Strategy Pattern: austauschbare Algorithmen typsicher umsetzen
- 4. Das Factory Pattern: typsichere Objekterzeugung ohne any
- 5. Das Observer Pattern: typsichere Events statt any-Payloads
- 6. Das Builder Pattern: fluente APIs mit typsicherer Verkettung
- 7. Pattern-Overuse vermeiden: Wann eine einfache Funktion reicht
- 8. Praxisbeispiel: PaymentStrategyFactory im Checkout
- 9. Design Patterns im Vergleich: Wann lohnt sich der Aufwand?
- 10. Zusammenfassung
- 11. FAQ
1. Warum klassische GoF-Patterns von TypeScript profitieren
Die klassischen Design Patterns aus dem "Gang of Four"-Buch wurden für statisch typisierte Sprachen wie C++ und Smalltalk entworfen. Ihr Kern ist Polymorphie über Interfaces und abstrakte Klassen, durchgesetzt vom Compiler. In reinem JavaScript existiert diese Durchsetzung nicht: Duck Typing bedeutet, dass eine fehlende Methode auf einer "Strategy"-Implementierung oder ein vertauschtes Feld im Event-Payload erst zur Laufzeit auffällt, oft erst bei einem echten Nutzer im Produktivsystem statt in der Code-Review.
TypeScript stellt genau die Disziplin wieder her, für die diese Patterns ursprünglich konzipiert wurden. Der Compiler erzwingt Interface-Verträge, meldet fehlende Methoden sofort, prüft Parameter- und Rückgabetypen und macht vergessene Fälle in einem Switch-Statement sichtbar, bevor der Code überhaupt gebaut wird. Das Typsystem wird dabei zur ausführbaren Dokumentation: Wer eine neue Strategy oder einen neuen Observer-Handler ergänzt, sieht direkt in der IDE, welche Methoden und welche Payload-Struktur der Vertrag verlangt, ganz ohne Blick in externe Dokumentation oder Tests.
2. Discriminated Unions und Exhaustiveness-Checks als Fundament
Fast jede typsichere Pattern-Implementierung in TypeScript stützt sich auf dieselbe Grundtechnik: die Discriminated Union. Dabei bekommt jedes Mitglied einer Union ein gemeinsames literales Feld, meist type oder kind, über das der Compiler innerhalb eines switch oder if automatisch den konkreten Typ eingrenzt. Innerhalb eines case 'paypal':-Blocks kennt TypeScript dann exakt die Felder, die nur bei diesem Union-Mitglied existieren, ganz ohne manuelles Type-Casting oder Laufzeitprüfungen.
Der zweite Baustein ist die Exhaustiveness-Check: Im default-Zweig eines Switch wird der verbleibende Wert einer Variable vom Typ never zugewiesen. Solange alle Fälle behandelt sind, kompiliert das problemlos. Sobald jemand später ein neues Union-Mitglied ergänzt, etwa eine weitere Zahlungsart, ohne den zugehörigen Fall zu behandeln, schlägt genau diese Zeile beim Kompilieren fehl. Dieses Muster zieht sich durch Factory, Strategy und Observer gleichermaßen und ist der eigentliche Grund, warum diese Patterns in TypeScript robuster sind als ihr JavaScript-Äquivalent.
3. Das Strategy Pattern: austauschbare Algorithmen typsicher umsetzen
Das Strategy Pattern kapselt austauschbare Algorithmen hinter einem gemeinsamen Interface, statt sie als lange if/else-Kette quer durch die Business-Logik zu verteilen. Für einen Checkout mit mehreren Zahlungsarten bedeutet das: Jede Zahlungsart bekommt eine eigene Klasse, die dasselbe PaymentStrategy-Interface implementiert, mit identischer Signatur für Gebührenberechnung und Zahlungsabwicklung.
Der Vorteil gegenüber der JavaScript-Variante zeigt sich beim Erweitern: Fügt man dem Interface eine neue Pflichtmethode hinzu, etwa refund(), markiert TypeScript sofort jede bestehende Strategy-Klasse als unvollständig, an der Stelle, an der sie definiert ist, nicht erst dort, wo sie irgendwann aufgerufen wird. Aufrufende Code-Stellen müssen die konkrete Implementierung nie kennen, sie arbeiten ausschließlich gegen das Interface und erhalten in allen Fällen dieselbe, vollständig typisierte Rückgabestruktur.
// Strategy contract: every payment strategy must implement this interface
interface PaymentStrategy {
readonly label: string;
calculateFee(amountCents: number): number;
process(amountCents: number): Promise<{ success: boolean; transactionId: string }>;
}
// Concrete strategy: credit card payments with a percentage-based fee
class CreditCardStrategy implements PaymentStrategy {
readonly label = 'Credit Card';
calculateFee(amountCents: number): number {
return Math.round(amountCents * 0.019 + 30);
}
async process(amountCents: number): Promise<{ success: boolean; transactionId: string }> {
// A real integration would call a payment service provider SDK here
return { success: true, transactionId: `cc_${Date.now()}` };
}
}
// Concrete strategy: PayPal payments with a different fee formula
class PayPalStrategy implements PaymentStrategy {
readonly label = 'PayPal';
calculateFee(amountCents: number): number {
return Math.round(amountCents * 0.029 + 39);
}
async process(amountCents: number): Promise<{ success: boolean; transactionId: string }> {
return { success: true, transactionId: `pp_${Date.now()}` };
}
}
// Concrete strategy: invoice payments, no processing fee at all
class InvoiceStrategy implements PaymentStrategy {
readonly label = 'Invoice';
calculateFee(): number {
return 0;
}
async process(amountCents: number): Promise<{ success: boolean; transactionId: string }> {
return { success: true, transactionId: `inv_${Date.now()}` };
}
}
4. Das Factory Pattern: typsichere Objekterzeugung ohne any
Das Factory Pattern zentralisiert die Entscheidung, welche konkrete Klasse für einen gegebenen Input instanziiert wird, und entkoppelt aufrufenden Code vollständig von den konkreten Klassen. Statt dass jede Checkout-Komponente selbst wissen muss, wie eine PayPalStrategy konstruiert wird, fragt sie eine Factory-Funktion nach der passenden Strategy für eine übergebene Zahlungsart.
In TypeScript wird die Zahlungsart als String-Literal-Union modelliert, etwa 'credit-card' | 'paypal' | 'invoice', und die Factory-Funktion prüft diesen Wert per switch. Der entscheidende Unterschied zur JavaScript-Variante: Der default-Zweig weist den verbleibenden Wert einer never-typisierten Variable zu. Wird die Union später um eine vierte Zahlungsart erweitert, ohne den passenden case zu ergänzen, bricht der Build sofort mit einem klaren Compiler-Fehler ab, statt dass die neue Zahlungsart im Betrieb stillschweigend durchfällt und niemals eine Strategy erhält.
// Discriminated union: every supported payment method as a string literal type
type PaymentMethod = 'credit-card' | 'paypal' | 'invoice';
// Typed factory function: the return type is PaymentStrategy on every branch
function createPaymentStrategy(method: PaymentMethod): PaymentStrategy {
switch (method) {
case 'credit-card':
return new CreditCardStrategy();
case 'paypal':
return new PayPalStrategy();
case 'invoice':
return new InvoiceStrategy();
default:
// Exhaustiveness check: fails to compile if a PaymentMethod case is missing
const exhaustiveCheck: never = method;
throw new Error(`Unhandled payment method: ${exhaustiveCheck}`);
}
}
// Usage inside a checkout service, fully decoupled from concrete strategy classes
async function chargeOrder(method: PaymentMethod, amountCents: number) {
const strategy = createPaymentStrategy(method);
const fee = strategy.calculateFee(amountCents);
return strategy.process(amountCents + fee);
}
5. Das Observer Pattern: typsichere Events statt any-Payloads
Das Observer Pattern entkoppelt Ereignisquellen von ihren Abonnenten, etwa einen Warenkorb, der Änderungen meldet, von einer UI-Komponente, die darauf reagiert. Das klassische Problem in JavaScript, auch mit Node's eingebautem EventEmitter: Der Payload eines Events ist typischerweise any, sodass Tippfehler im Event-Namen oder falsch benannte Felder im Payload erst zur Laufzeit auffallen, oft weit entfernt vom eigentlichen Fehler.
Mit einer Discriminated Union pro Event-Typ und einem generischen Emitter lässt sich das vollständig auflösen. Jeder Event-Typ, etwa item-added oder checkout-started, bekommt eine eigene Payload-Struktur. Ein Listener, der sich auf item-added registriert, erhält automatisch nur die dazu passenden Felder wie sku und quantity, ganz ohne manuelles Casting. Tippfehler im Event-Namen und falsch typisierte Payloads werden so zu Compile-Zeit-Fehlern statt zu stillen Laufzeit-Bugs, die erst im Bugtracking auffallen.
// Discriminated union describing every cart event and its payload shape
type CartEvent =
| { type: 'item-added'; sku: string; quantity: number }
| { type: 'item-removed'; sku: string }
| { type: 'checkout-started'; totalCents: number };
type Listener<E extends CartEvent> = (event: E) => void;
// Generic, typed event emitter: no "any" payloads, subscribers get narrowed types
class TypedCartEmitter {
private listeners: { [K in CartEvent['type']]?: Listener<Extract<CartEvent, { type: K }>>[] } = {};
on<K extends CartEvent['type']>(type: K, listener: Listener<Extract<CartEvent, { type: K }>>): void {
const bucket = this.listeners[type] ?? [];
bucket.push(listener as never);
this.listeners[type] = bucket as never;
}
emit(event: CartEvent): void {
const bucket = this.listeners[event.type];
bucket?.forEach((listener) => listener(event as never));
}
}
// Consumer code receives a fully typed payload, no manual casting required
const emitter = new TypedCartEmitter();
emitter.on('item-added', (event) => {
console.log(`Added ${event.quantity}x ${event.sku}`);
});
6. Das Builder Pattern: fluente APIs mit typsicherer Verkettung
Das Builder Pattern konstruiert komplexe Objekte schrittweise, statt einen Konstruktor mit acht oder mehr Parametern anzubieten, bei dem die Reihenfolge kaum noch nachvollziehbar ist. Besonders sinnvoll ist es, wenn ein Objekt viele optionale Felder hat oder vor der endgültigen Erzeugung validiert werden muss, etwa eine Bestellung mit Pflichtfeldern wie Kunde und Lieferadresse sowie optionalen Feldern wie einer Anmerkung.
In TypeScript gibt jede Setter-Methode this mit dem passenden Typ zurück, sodass die Verkettung vollständig typsicher bleibt und die IDE nach jedem Aufruf korrekt die verfügbaren nächsten Methoden vorschlägt. Die abschließende build()-Methode prüft Pflichtfelder zur Laufzeit und liefert ein readonly-typisiertes Ergebnisobjekt zurück, das garantiert vollständig ist. Für Objekte mit nur zwei oder drei Pflichtfeldern lohnt sich der Aufwand allerdings selten, dazu mehr im nächsten Abschnitt.
interface Order {
readonly customerId: string;
readonly items: ReadonlyArray<{ sku: string; quantity: number }>;
readonly shippingAddress: string;
readonly note?: string;
}
// Fluent builder: every method returns "this", enabling typed method chaining
class OrderBuilder {
private customerId?: string;
private items: { sku: string; quantity: number }[] = [];
private shippingAddress?: string;
private note?: string;
forCustomer(customerId: string): this {
this.customerId = customerId;
return this;
}
addItem(sku: string, quantity: number): this {
this.items.push({ sku, quantity });
return this;
}
shipTo(address: string): this {
this.shippingAddress = address;
return this;
}
withNote(note: string): this {
this.note = note;
return this;
}
build(): Order {
if (!this.customerId || !this.shippingAddress) {
throw new Error('customerId and shippingAddress are required');
}
return {
customerId: this.customerId,
items: this.items,
shippingAddress: this.shippingAddress,
note: this.note,
};
}
}
// Usage: readable, chainable, and the final object is fully typed and readonly
const order = new OrderBuilder()
.forCustomer('cust_42')
.addItem('SKU-1', 2)
.shipTo('Musterstrasse 1, 10115 Berlin')
.build();
7. Pattern-Overuse vermeiden: Wann eine einfache Funktion reicht
Nicht jede Business-Logik braucht ein formales Design Pattern. Ein Interface, eine konkrete Implementierung und eine Wrapper-Klasse für einen Algorithmus, der niemals ausgetauscht wird, erzeugen nur zusätzliche Dateien, zusätzliche Indirektion und zusätzlichen kognitiven Aufwand für jeden, der den Code später liest, ohne dass dem ein realer Nutzen gegenübersteht. TypeScript macht Patterns sicherer, aber es macht ein unpassend gewähltes Pattern nicht kostenlos.
Ein typisches Beispiel: eine TaxStrategy mit genau einer Implementierung für die deutsche Mehrwertsteuer, verpackt in eine TaxCalculator-Klasse, die intern nur an die Strategy delegiert. Solange es keinen zweiten, aktuell benötigten Steuersatz gibt, leistet eine einzelne typisierte Funktion exakt dasselbe, ohne Klassenhierarchie und ohne Dependency Injection. Die Faustregel: Ein Pattern lohnt sich, sobald mindestens zwei reale, aktuell gebrauchte Varianten existieren, nicht vorsorglich für eine hypothetische Erweiterung, die vielleicht nie eintritt.
// Over-engineered: a full Strategy hierarchy for one fixed, never-changing algorithm
interface TaxStrategy {
calculate(amount: number): number;
}
class GermanVatStrategy implements TaxStrategy {
calculate(amount: number): number {
return Math.round(amount * 0.19 * 100) / 100;
}
}
class TaxCalculator {
constructor(private readonly strategy: TaxStrategy) {}
calculate(amount: number): number {
return this.strategy.calculate(amount);
}
}
const calculator = new TaxCalculator(new GermanVatStrategy());
calculator.calculate(100);
// Equivalent, simpler: a plain typed function does the same job with no ceremony
function calculateGermanVat(amount: number): number {
return Math.round(amount * 0.19 * 100) / 100;
}
calculateGermanVat(100);
8. Praxisbeispiel: PaymentStrategyFactory im Checkout
Die Kombination aus den Abschnitten drei und vier zeigt den praktischen Nutzen am deutlichsten: In einem Headless-Checkout wählt der Kunde im Frontend eine Zahlungsart, das Backend ruft createPaymentStrategy(method) auf und erhält eine vollständig typisierte PaymentStrategy-Instanz zurück. Der Checkout-Service selbst kennt zu keinem Zeitpunkt CreditCardStrategy, PayPalStrategy oder InvoiceStrategy als konkrete Klassen, er arbeitet ausschließlich gegen das Interface und bleibt dadurch unabhängig von der Anzahl und Art der unterstützten Zahlungsarten.
Der praktische Gewinn zeigt sich vor allem beim Testen und Erweitern: Jede Strategy lässt sich isoliert mit Unit-Tests prüfen, ohne die Factory oder andere Strategies zu mocken. Eine neue Zahlungsart wie Klarna bedeutet lediglich eine neue Klasse plus einen neuen case in der Factory, bestehende Strategies bleiben unangetastet, ganz im Sinne des Open/Closed-Prinzips. Und sobald die PaymentMethod-Union um diese neue Zahlungsart wächst, meldet der Compiler an jeder Stelle, an der noch ein case fehlt, praktisch ein kostenloser Regressionstest bei jedem tsc-Lauf.
9. Design Patterns im Vergleich: Wann lohnt sich der Aufwand?
Jedes der hier vorgestellten Patterns hat ein Szenario, in dem es die richtige Wahl ist, und ein Szenario, in dem es reine Zeremonie ohne Mehrwert erzeugt. Die folgende Übersicht fasst zusammen, wann ein Pattern angemessen ist und wann eine einfachere Lösung ausreicht.
| Szenario | Nicht empfohlen (Overkill) | Empfohlen |
|---|---|---|
| Einzelner, fixer Algorithmus | Strategy Pattern mit Interface | Einfache typisierte Funktion |
| Mehrere austauschbare Algorithmen | if/else-Kette im Businesscode | Strategy Pattern |
| Einfache Objekterstellung ohne Bedingungen | Factory-Klasse mit Registry | Direkter Konstruktor-Aufruf |
| Komplexe, bedingte Objekterstellung | Objektliteral mit verschachtelten Bedingungen | Factory Pattern mit Exhaustiveness-Check |
| Einmalige Objekterstellung, wenige Felder | Builder-Klasse mit Fluent API | Objektliteral oder Konstruktor |
| Viele optionale Konstruktor-Parameter | Konstruktor mit acht oder mehr Parametern | Builder Pattern |
| Statische Liste fester Listener | Volles Observer-Framework | Direkter Funktionsaufruf |
| Dynamisches Pub/Sub mit vielen Subscribern | Manuelles Array von Callbacks ohne Typisierung | Observer Pattern mit typisiertem EventEmitter |
Die Tabelle macht ein wiederkehrendes Muster sichtbar: Die Entscheidung für oder gegen ein Pattern sollte sich an der tatsächlich vorhandenen Variabilität der Anforderungen orientieren, nicht an einer vorsorglich antizipierten Flexibilität, die vielleicht nie gebraucht wird. TypeScript macht die korrekte Anwendung eines Patterns günstiger und sicherer, es macht die falsche Wahl eines Patterns aber nicht kostenlos.
Mironsoft
TypeScript-Architektur, Design Patterns und Headless-Commerce-Integrationen für Magento-Shops
Typsichere Architektur für euer TypeScript-Projekt?
Wir entwickeln typsichere Frontend- und Backend-Bausteine für Magento- und Headless-Commerce-Projekte, von Factory- und Strategy-Implementierungen bis zu vollständig typisierten Event-Systemen, die Fehler schon beim Kompilieren statt erst beim Kunden zeigen.
TypeScript-Architektur-Review
Analyse bestehender Pattern-Nutzung, Priorisierung nach Wartbarkeit
Design-Pattern-Refactoring
Factory-, Strategy- und Observer-Implementierungen typsicher nachrüsten
Headless-Commerce-Integration
Typisierte Checkout- und Payment-Bausteine für Magento und Frontend-Frameworks
10. Zusammenfassung
Klassische GoF-Patterns wie Factory, Strategy, Observer und Builder werden in TypeScript nicht neu erfunden, sondern erhalten die statische Disziplin zurück, für die sie ursprünglich entworfen wurden. Discriminated Unions und Exhaustiveness-Checks über never sind dabei das gemeinsame Fundament: Sie sorgen dafür, dass fehlende Fälle in einer Factory, unvollständige Strategy-Implementierungen oder falsch typisierte Event-Payloads bereits beim Kompilieren auffallen, nicht erst beim Kunden im Live-Betrieb.
Genauso wichtig wie die korrekte Umsetzung ist die bewusste Entscheidung, wann ein Pattern überhaupt gebraucht wird. Ein einzelner, fixer Algorithmus profitiert nicht von einer Strategy-Hierarchie, ein Objekt mit zwei Pflichtfeldern nicht von einem Builder. Erst wenn echte Variabilität vorhanden ist, wie bei mehreren Zahlungsarten in einem Checkout, zahlt sich die zusätzliche Struktur in Form von Testbarkeit, Erweiterbarkeit und Compiler-Unterstützung tatsächlich aus.
Design Patterns in TypeScript - Das Wichtigste auf einen Blick
Discriminated Unions
Typsichere Grundlage für Factory, Strategy und Observer, mit Exhaustiveness-Check über never.
Factory Pattern
Objekterzeugung ohne any, Compiler-Fehler statt stiller Runtime-Überraschung bei fehlenden Fällen.
Strategy & Observer
Austauschbare Algorithmen und typisierte Events statt any-Payloads und langer if/else-Ketten.
Pattern-Overuse vermeiden
Erst ab mindestens zwei realen Varianten lohnt sich ein Pattern, sonst reicht eine einfache Funktion.