Startseite / Artikel / Auswahl von TypeScript-Mustern nach der Komplexität, die sie beseitigen.

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.

5991 Wörter

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 ProductId in eine Funktion gelangen, die eigentlich einen UserId erwartet?
  • Kann einer Komponente Eigenschaften gegeben werden, die miteinander im Widerspruch stehen?
  • Falls ein neuer Zustand hinzugefügt wird, muss dann jeder Nutzer damit umgehen?
  • Kann eine Implementierung ausgetauscht werden, ohne die Aufrufer zu beeinflussen?
  • Kann eine Abstraktion wiederverwendet werden, ohne auf 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
  • Instrumentierung und Verfolgung
  • 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
  • Warteschlangenverarbeitung
  • Wiederholungen
  • Aufgeschobene Verarbeitung
  • Auditspeicherung
  • 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:

    1. Angesichts einer 2.000 Zeilen langen React-Komponente: Wie wählt man aus, welche Abstraktion eingeführt werden soll?
    2. Wann lehnt man ein scheinbar passendes Muster ab?
    3. Wie erkennt man, dass eine Abstraktion vorzeitig ist?
  • Wann ist Komposition vorzuziehen statt Vererbung?
  • Wie verhindert man, dass ein Singleton zu einem globalen, veränderbaren Zustand wird?
  • Grundlagen von TypeScript:

    1. Wie würde man eine Anfrage mit Zuständen wie Laden, Erfolg, Fehler und Wiederholung modellieren?
    2. Wie verhindert man ungültige Kombinationen von React-Props?
    3. Inwiefern unterscheiden sich unknown, any und never?
    4. Wann sollte man type statt interface wählen und umgekehrt?
    5. Wie interagiert keyof mit Generics?

    Fortgeschrittener TypeScript:

    1. Was ist ein konkreter Anwendungsfall für bedingte Typen?
    2. Was bewirkt infer?
    3. Was ist ein mappierter Typ?
    4. Was sind distributive bedingte Typen?
    5. Wann lohnen sich branded Types?
    6. Welches Problem löst satisfies?
    7. Wie verändert as const die Inferenz?

    Reale Entwurfsaufgaben:

    1. Entwerfen Sie einen typsicheren Event-Bus.
    2. Entwerfen Sie einen typsicheren API-Client.
    3. Entwerfen Sie ein Berechtigungssystem unter Verwendung von TypeScript.
    4. Entwerfen Sie eine Zahlungsabstraktion, die Stripe, PayPal und einen dritten Anbieter unterstützt.
    5. Wie würden Sie eine Repository-Schicht zu einer bestehenden React-Anwendung hinzufügen, ohne diese neu schreiben zu müssen?
    6. Wie würden Sie WebSocket-Ereignisse mit unterschiedlichen Payloads typisieren?
    7. Wie würden Sie verhindern, dass ein ProductId übergeben wird, wo eigentlich ein UserId erforderlich 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.