Klassische Design Patterns typsicher in TypeScript umsetzen
<T>
type
TypeScript · Design Patterns · Type Safety · Software-Architektur
Klassische Design Patterns typsicher in TypeScript umsetzen
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.

12 Min. Lesezeit Factory · Strategy · Observer · Builder TypeScript 5 · Node.js · Headless Commerce

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.

11. FAQ: Design Patterns in TypeScript

1Warum sind Design Patterns in TypeScript sicherer als in JavaScript?
TypeScript erzwingt Interface-Verträge zur Compile-Zeit, sodass fehlende Methoden und vergessene Switch-Fälle sofort sichtbar werden, statt erst zur Laufzeit im Produktivbetrieb.
2Was ist eine Discriminated Union und wozu dient sie bei Patterns?
Ein gemeinsames literales Feld wie type grenzt den konkreten Typ innerhalb eines switch automatisch ein, ganz ohne manuelles Casting.
3Wie funktioniert eine Exhaustiveness-Check mit never?
Der default-Zweig weist den Restwert einer never-Variable zu. Fehlt ein Fall für ein neues Union-Mitglied, schlägt der Build fehl.
4Wann sollte ich das Factory Pattern statt eines einfachen Konstruktors nutzen?
Sobald die Objekterstellung von einer Bedingung mit mehreren möglichen Klassen abhängt. Bei unbedingter Erstellung reicht ein direkter Konstruktor.
5Was unterscheidet das Strategy Pattern von einer einfachen if/else-Kette?
Jeder Algorithmus steckt in einer eigenen Klasse hinter einem gemeinsamen Interface, statt als lange Bedingungskette verteilt zu sein.
6Wie macht TypeScript das Observer Pattern typsicherer?
Eine Discriminated Union pro Event-Typ ersetzt any-Payloads, Listener erhalten automatisch die korrekt typisierten Felder.
7Wofür eignet sich das Builder Pattern und wann ist es überdimensioniert?
Sinnvoll bei vielen optionalen Feldern oder Validierungsbedarf. Bei zwei oder drei Pflichtfeldern reicht ein Objektliteral oder Konstruktor.
8Wann ist ein Design Pattern Overengineering?
Wenn nur eine einzige, aktuell nicht austauschbare Implementierung existiert und das Pattern nur zusätzliche Indirektion ohne Mehrwert erzeugt.
9Wie kombiniere ich Factory und Strategy im Checkout sinnvoll?
Eine PaymentStrategyFactory liefert die passende PaymentStrategy zurück, der Checkout-Service kennt nur das Interface, nicht die konkreten Klassen.
10Brauche ich Klassen für Design Patterns in TypeScript oder reichen Funktionen?
Nicht zwingend. Typisierte Funktionen und Objektliterale reichen oft aus, Klassen lohnen sich vor allem bei Zustand oder echter Vererbung.