Strona główna / Artykuły

Wzorce bezpieczeństwa typów w TypeScript: generyki, DI i kiedy wzorzec się opłaca

Wzory projektowe i TypeScript na poziomie typów dla bezpieczeństwa typów: generyki, iniekcja zależności, unikalne identyfikatory oraz sposoby rozpoznawania sytuacji, gdy wzór redukuje złożoność.

Wydawca: REACTAPP.TOP24 min czytania5587 słów

Źródła: From Design Patterns to Type-Level Architecture — A Practical Guide from 10+ Years of Production Experience

Większość zespołów stosuje TypeScript do autodopisywania i wykrywania błędów pisowni, a następnie odkrywa jego prawdziwą wartość: umożliwia on kodowanie decyzji projektowych tak, aby kompilator je egzekwował. Ten przewodnik omawia klasyczne wzorce orientowane na obiekty oraz techniki na poziomie typów, które są najważniejsze w dużych projektach frontendowych i full-stack, oraz pokazuje, jak zdecydować, czy dany wzorzec jest warte swoich kosztów.

Traktuj system typów jako narzędzie, które może odpowiadać na pytania architektoniczne:

  • Czy ta kombinacja wartości stanu jest rzeczywiście możliwa?
  • Czy ta API może zwrócić strukturę, której reszta kodu się nie spodziewa?
  • Czy ProductId może trafić do funkcji, która oczekuje UserId?
  • Czy komponent może otrzymać atrybuty, które są ze sobą sprzeczne?
  • Czy po dodaniu nowego stanu każdy użytkownik będzie zmuszony się z nim uporać?
  • Czy implementację można zamienić bez wpływu na funkcje, które ją wywołują?
  • Czy abstrakcję można ponownie wykorzystać bez ucieczki do any?
  • Wzorzec to nie funkcja, którą dodaje się później. To nazwa rozwiązania, które rozpoznajemy, gdy pojawia się powtarzający się problem.

    Cztery rodziny wzorców oraz system typów

    Klasyczny podział obejmuje wzorce kreacyjne, strukturalne i behawioralne; TypeScript dodaje czwartą rodzinę technik na poziomie typów, opartych na generykach, unijach i narzędziach bezpieczeństwa.

                         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
    

    Rzeczywiste aplikacje rzadko używają pojedynczego wzorca. Typowy przepływ danych w React łączy kilka takich wzorców, a każda granica może mieć określone typy danych:

    React Component
          │
          ▼
    Custom Hook
          │
          ▼
    Service
          │
          ▼
    Repository
          │
          ▼
    API Client
          │
          ▼
    Result<T, E>
          │
          ▼
    Discriminated Union
    

    Gdy typy przepływają przez cały ten łańcuch, TypeScript staje się opisem tego, w jaki sposób system może się zachowywać.

    Zacznij od problemu, a nie od wzorca

    Częstym pułapką jest rozumowanie w niewłaściwym kierunku:

    "I know Factory Pattern.
    Where can I use Factory?"
    

    Wybór wzoru na początku prowadzi do abstrakcji, których nikt nie potrzebuje. Zamiast tego niech to problem kieruje wyborem:

    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?
    

    Rozważmy przepływ płatności, w którym wprowadzono szereg weryfikacji metody płatności:

    if (paymentMethod === "card") {
      // ...
    }
    
    if (paymentMethod === "paypal") {
      // ...
    }
    if (paymentMethod === "upi") {
      // ...
    }
    

    Odpowiedź odruchowa jest zazwyczaj taka:

    Don’t immediately think:
    

    „To wymaga strategii.” Zanim się do tego zabierzesz, zastanów się, czy te wersje rzeczywiście są wymiennymi algorytmami opartymi na jednym kontrakcie. Jeśli tak, strategia się nadaje. Jeśli prawdziwym problemem jest to, że niektóre pola mają sens tylko w kontekście określonych metod, lepszym narzędziem może być zjednoczenie dyskryminowane, które uniemożliwia reprezentację nieważnych kombinacji. Znajomość katalogu jest prosta; umiejętność dopasowania wpisu do konkretnego przypadku to prawdziwa umiejętność.

    Wzory kreatywne

    Singleton: jedna wspólna instancja

    Singleton zapewnia, że klasa ma dokładnie jedną instancję. Prywatny konstruktor blokuje dostęp spoza metody new, a statyczna metoda w sposób opóźniony tworzy i przechowuje tę instancję w pamięci 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);
      }
    }
    

    Osoby dzwoniące proszą o obiekt współdzielony zamiast tworzyć go samodzielnie:

    const logger = Logger.getInstance();
    logger.log("Application started");
    

    Każdy konsument ostatecznie wskazuje na ten sam obiekt:

                      Logger
                        │
                 getInstance()
                        │
                        ▼
                 ┌───────────┐
                 │  Logger   │
                 │ Instance  │
                 └───────────┘
                   ▲       ▲
                   │       │
              Service A  Service B
    

    Do rozważenia należą następujące kandydaci:

    • systemy logowania
    • menedżerowie analiz
    • osoby odpowiedzialne za konfigurację
    • niektórzy menedżerowie połączeń
    • inne usługi infrastrukturalne o charakterze wspólnym

    Problem polega na tym, że Singleton to w rzeczywistości globalny dostęp w przebraniu: testy stają się trudniejsze do izolacji, zależności znikają z informacji o strukturze kodu, cykl życia staje się niejasny, a wspólny, zmienialny stan zmienia się w nieśledzalny sposób. W nowoczesnym frontendzie instancja eksportowana z modułu ES jest już współdzielona, a iniekcja zależności, React Context lub biblioteki do zarządzania stanem zapewniają takie same korzyści przy jednoczesnej widoczności struktury połączeń.

    Factory: ukrycie klasy, która jest tworzona

    Factory przenosi decyzję o tym, która konkretna klasa ma zostać zainstancjowana, z użytkownika. Porównaj to z bezpośrednią konstrukcją:

    const payment = new StripePayment();
    

    z zleconą budową:

    const payment = PaymentFactory.create("stripe");
    

    Oba dostawcy implementują tę samą interfejs, a fabryka mapuje zbiór literów tekstowych na odpowiednią klasę. Ponieważ typ parametru to "stripe" | "paypal", nazwy niesprawdzane powodują błąd kompilacji:

    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();
        }
      }
    }
    

    Wizualnie fabryka jest widoczna jako widelczyk z nazwą dostawcy:

                  PaymentFactory
                           │
              ┌────────────┴────────────┐
              │                         │
           "stripe"                  "paypal"
              │                         │
              ▼                         ▼
         StripePayment             PayPalPayment
    

    Fabryka okazuje się przydatna wtedy, gdy:

    • budowa opiera się na rzeczywistej logice
    • kilka implementacji korzysta z jednego kontraktu
    • klienci nie powinni znać konkretnych klas
    • implementacje muszą rozwijać się niezależnie od tych, którzy je wykorzystują

    W przypadku prostej budowy, jak ta poniżej, dodaje się tylko warstwę, przez którą trzeba przejść, aby to zrozumieć:

    new User();
    

    Abstrakcyjna fabryka: rodziny powiązanych obiektów

    Abstrakcyjna fabryka rozszerza tę koncepcję na grupy obiektów, które muszą być spójne. W zestawie interfejsu przeznaczonym dla kilku platform przycisk internetowy nigdy nie powinien być łączony z modalem mobilnym, dlatego każda fabryka tworzy jedną spójną rodzinę obiektów:

    interface Button {
      render(): void;
    }
    
    interface Modal {
      open(): void;
    }
    
    interface UIFactory {
      createButton(): Button;
      createModal(): Modal;
    }
    
                      UIFactory
                        │
               ┌────────┴────────┐
               ▼                 ▼
         WebUIFactory       MobileUIFactory
               │                 │
          ┌────┴────┐       ┌────┴────┐
          ▼         ▼       ▼         ▼
       Button     Modal   Button     Modal
    

    Jest potężny, ale łatwo go przeciążyć. W większości aplikacji frontendowych zwykła kompozycja komponentów pozwala osiągnąć ten cel z mniejszą ilością formalności.

    Builder: kontrolowana, krok po kroku budowa

    Builder jest przydatny, gdy obiekt ma wiele opcjonalnych ustawień lub jest konfigurowany etapami. Każdy metodę ustawiająca aktualizuje prywatną konfigurację i zwraca this, umożliwiając łączenie operacji:

    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;
      }
    }
    

    Składanie żądania przedstawia się jako wyraźne kroki:

    const request = new RequestBuilder()
      .setMethod("POST")
      .setHeaders({
        "Content-Type": "application/json"
      })
      .setBody(JSON.stringify(data))
      .build();
    

    Płynna składnia jest efektem ubocznym, a nie celem. Chodzi o to, aby złożone konstrukcje były jawne i znajdowały się w jednym miejscu, gdzie build() może je również zweryfikować, na przykład odrzucając treść ciała w żądaniu GET.

    Wzory strukturalne

    Adapter: stabilna umowa wokół API kogoś innego

    Adapter to jeden z najczęściej używanych wzorów w kodzie aplikacji. Załóżmy, że twój kod oczekuje takiej wewnętrznej umowy:

    interface PaymentGateway {
      pay(amount: number): Promise<void>;
    }
    

    podczas gdy SDK od strony trzeciej oferuje metodę o innym nazwie:

    class LegacyPaymentSDK {
      makePayment(value: number) {
        // third-party implementation
      }
    }
    

    Cienki adapter implementuje twoją interfejs i przekształca wywołania:

    class PaymentAdapter implements PaymentGateway {
      constructor(
        private readonly sdk: LegacyPaymentSDK
      ) {}
    
    async pay(amount: number) {
        this.sdk.makePayment(amount);
      }
    }
    
    Application
         │
         ▼
    PaymentGateway
         ▲
         │
    PaymentAdapter
         │
         ▼
    Third-party SDK
    

    Aplikacja teraz polega na jednej interfejsie, który należy do ciebie:

    PaymentGateway
    

    a nie na każdym dostawcy za nim:

    Stripe
    PayPal
    LegacySDK
    SomeFutureProvider
    

    Wsparcie nowego dostawcy oznacza konieczność napisania kolejnego adaptera.

    Facade: jedna wywołanie dla wieloetapowego procesu

    Facade stanowi prosty punkt wejścia do złożonego podsystemu. Proces logowania może dotyczyć kilku usług:

    Authentication
         +
    User Service
         +
    Permissions
         +
    Notification
         +
    Analytics
    

    Bez fasady każdy komponent, który loguje użytkownika, sam koordynuje tę sekwencję:

    auth.login();
    user.load();
    permission.load();
    analytics.track();
    

    Fasada otacza tę orkiestrację raz:

    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;
      }
    }
    

    a komponent skraca się do jednego wywołania:

    await appFacade.login(username, password);
    
                   Component
                         │
                         ▼
                     AppFacade
                         │
           ┌─────────────┼─────────────┐
           ▼             ▼             ▼
         Auth          User        Permission
        Service       Service       Service
    

    To jest przydatne, gdy kolejność kroków ma znaczenie.

    Dekorator: dodawanie zachowań poprzez otaczanie

    Dekorator dodaje nowe zachowania bez zmiany oryginalnej implementacji, ponieważ obiekt otaczający i obiekt otoczony dzielą tę samą interfejs. Umowa:

    interface Logger {
      log(message: string): void;
    }
    

    Prosta implementacja:

    class ConsoleLogger implements Logger {
      log(message: string) {
        console.log(message);
      }
    }
    

    Dekorator, który przechowuje dowolny Logger i dodaje do wiadomości prefiks z datą i godziną:

    class TimestampLogger implements Logger {
      constructor(
        private readonly logger: Logger
      ) {}
    
    log(message: string) {
        this.logger.log(
          `[${new Date().toISOString()}] ${message}`
        );
      }
    }
    

    Otoczenie to po prostu konstrukcja:

    const logger = new TimestampLogger(
      new ConsoleLogger()
    );
    

    Ponieważ każdy dekorator jest sam w sobie Logger, są one układane warstwowo:

    Logger
      │
      ▼
    ConsoleLogger
      │
      ▼
    TimestampLogger
      │
      ▼
    AdditionalDecorator
    

    Ta sama koncepcja występuje pod innymi nazwami:

    • łańcuchy middleware
    • funkcje otaczające
    • komponenty wyższego rzędu w React
    • logowanie
    • warstwy cache’owania
    • sprawdzania uprawnień
    • instrumentacja i śledzenie

    Wzory zachowań

    Strategia: wymienne algorytmy

    Strategia eliminuje złożone reguły biznesowe. Weźmy na przykład typ poziomu klienta:

    type CustomerType =
      | "regular"
      | "premium"
      | "enterprise";
    

    Warunkowa implementacja umieszcza każdą zasadę w jednej funkcji:

    function calculateDiscount(
      type: CustomerType,
      price: number
    ) {
      if (type === "regular") {
        return price;
      }
    
    if (type === "premium") {
        return price * 0.9;
      }
      return price * 0.8;
    }
    

    Dzięki Strategy każde zasada staje się klasą za wspólną interfejsem:

    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
    

    Dodawanie obecnie nowego poziomu zazwyczaj oznacza wprowadzenie nowej strategii zamiast edycji istniejącej logiki, co jest praktycznym zastosowaniem zasady Otwarto-Zamkniętości. Przy trzech małych regułach warunki są wystarczające; strategia okazuje się przydatna, gdy reguły zaczynają mieć własne zależności lub testy.

    Observer: powiadomienia jeden-do-wielu

    Observer umożliwia jednemu obiektowi podmiotowemu wysyłanie powiadomień do dowolnej liczby obiektów odbiorczych, gdy coś się zmienia:

                     Subject
                         │
              ┌──────────┼──────────┐
              ▼          ▼          ▼
          Observer A Observer B Observer C
    

    Niewielki emiter typowany przechowuje słuchacze w Set i zwraca funkcję do czyszczenia, gdy zostanie zarejestrowany słuchacz:

    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();
    

    Ta funkcja czyszczenia to zarządzanie cyklem życia komponentu. Zapomnienie o jej wywołaniu skutkuje:

    • wyciekami pamięci spowodowanymi słuchaczami, które przetrwają swoich właścicieli
    • podwójnym obsługiwaniem zdarzeń, gdy słuchacz zostanie dołączony dwa razy
    • starymi zamykaniami, które odczytują przestarzałe wartości
    • efektami ubocznymi, które występują po zniknięciu komponentu

    W React dokładnie to jest tym, co zwracamy z funkcji zwrotnej useEffect.

    Polecenie: działania jako obiekty

    Polecenie przekształca działanie w obiekt o jednolitej interfejsie:

    interface Command {
      execute(): void;
    }
    

    Konkretne polecenia je wdrażają:

    class SaveCommand implements Command {
      execute() {
        console.log("Saving...");
      }
    }
    
    class UndoCommand implements Command {
      execute() {
        console.log("Undo");
      }
    }
    

    Traktowanie działań jako wartości jest przydatne, gdy potrzebujesz:

    • historii tego, co się wydarzyło
    • możliwości cofnięcia i ponownego wykonania
    • wykonania w kolejce
    • prób ponownych
    • odroczonych wykonań
    • rejestracji działań do audytu

    Prawdziwe cofnięcie zwykle wymaga, aby każda komenda sama się odwróciła, dlatego wersje produkcyjne często dodają metodę undo() obok execute().

    User Action
        │
        ▼
     Command
        │
        ├── execute()
        │
        ▼
     Receiver
    

    Wzorce dat i składu

    Repository: izolacja dostępu do danych

    Gdy dostęp do danych staje się złożony, Repository tworzy umowę pomiędzy logiką biznesową a elementem, który przechowuje lub pobiera dane:

    UI
     │
     ▼
    Hook / Controller
     │
     ▼
    Service
     │
     ▼
    Repository
     │
     ├── REST
     ├── GraphQL
     ├── IndexedDB
     └── Cache
    

    Umowa opisuje, o co aplikacja może prosić:

    interface UserRepository {
      getUser(id: string): Promise<User>;
      getUsers(): Promise<User[]>;
    }
    

    Jedna z implementacji komunikuje się z API REST:

    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();
      }
    }
    

    Kod biznesowy zależy od interfejsu:

    UserRepository
    

    nie w transporcie:

    fetch()
    axios()
    graphqlClient()
    

    To ma znaczenie, gdy zmienia się źródło danych: przejście z REST na GraphQL, dodanie pamięci cache IndexedDB lub użycie wersji tymczasowej w pamięci operacyjnej do testów. Należy pamiętać, że response.json() zwraca wartość bez określenia typu, więc repozytorium jest również odpowiednim miejscem do weryfikacji odpowiedzi.

    Kompozycja zamiast dziedziczenia

    W pracy nad interfejsem użytkownika kompozycja jest ważniejsza niż jakikolwiek wzorzec oparty na dziedziczeniu. Zamiast jednego komponentu, który kontroluje wszystko:

    MegaComponent
     ├── Authentication
     ├── Table
     ├── Filters
     ├── Modal
     ├── Notifications
     ├── API calls
     └── Business logic
    

    Podziel odpowiedzialności na konkretne zadania:

    Dashboard
     ├── Header
     ├── Sidebar
     ├── FilterPanel
     ├── DataTable
     └── NotificationPanel
    

    W Reactzie polega to po prostu na układaniu komponentów warstwowo:

    <Dashboard>
      <Header />
      <Sidebar />
      <MainContent />
    </Dashboard>
    

    Każdy element można zrozumieć, przetestować i zastąpić osobno. Aby dowiedzieć się więcej na temat dzielenia zbyt rozbudowanych komponentów, zapoznaj się z rozwiązaniami problemu przeładowania propów w React za pomocą kompozycji i slotów.

    Modelowanie stanu za pomocą systemu typów

    Od tego momentu sam TypeScript stanowi narzędzie architektoniczne.

    Unie dyskryminowane dla stanu żądania

    Żądanie przechodzi przez różne fazy, w których przekazywane są różne dane. Unia dyskryminowana nadaje każdej fazie własną strukturę, połączoną wspólnym polem status:

    type RequestState =
      | {
          status: "idle";
        }
      | {
          status: "loading";
        }
      | {
          status: "success";
          data: User[];
        }
      | {
          status: "error";
          error: string;
        };
    

    Włączenie dyskryminanta zawęża typ w każdej gałęzi:

    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;
      }
    }
    

    Kompilator śledzi, które pola istnieją po każdej weryfikacji:

    status = "success"
            ↓
    data exists
    
    status = "error"
            ↓
    error exists
    

    Porównaj powszechny model boolean-and-optionals:

    interface State {
      loading: boolean;
      data?: User[];
      error?: string;
    }
    

    Nic nie przeszkadza mu opisać stanu, który nigdy nie powinien wystąpić:

    {
      loading: true,
      data: [...],
      error: "Something failed"
    }
    

    Związek ten sprawia, że takie kombinacje są bardzo trudne do przedstawienia. Modeluj ważne stany zamiast rozproszać opcjonalne właściwości i polegać na tym, że wszyscy połączą je prawidłowo.

    Typy wyników dla wyraźnego awarii

    Wiele operacji ma dokładnie dwa wyniki:

    Success
       OR
    Failure
    

    Typ Result jest w pełni określany za pomocą dyskryminantu logicznego:

    type Result<T, E> =
      | {
          success: true;
          data: T;
        }
      | {
          success: false;
          error: E;
        };
    

    Funkcja, która to zwraca:

    function getUser(): Result<User, string> {
      return {
        success: true,
        data: user
      };
    }
    

    Osoba dzwoniąca musi sprawdzić wartość success przed dostępem do data lub error:

    const result = getUser();
    
    if (result.success) {
      console.log(result.data);
    } else {
      console.error(result.error);
    }
    
                 Service
                    │
                    ▼
              Result<T, E>
               /       \
              /         \
             ▼           ▼
          Success      Failure
            │             │
           data          error
    

    To odnosi się do typowych niepowodzeń biznesowych, takich jak „adres e-mail jest już zarejestrowany”. Wyjątki nadal dotyczą prawdziwych błędów i awarii infrastruktury; typ Result pozwala jedynie uwidocznić przewidywane niepowodzenia w podpisie.

    Generyki i inne narzędzia na poziomie typów

    Generyki zachowują związek pomiędzy wejściem a wyjściem

    Użycie any powoduje utratę informacji:

    function identity(value: any): any {
      return value;
    }
    

    Parametr typu to zachowuje:

    function identity<T>(value: T): T {
      return value;
    }
    

    Wynik wyprowadzony jest z następującego argumentu:

    const a = identity("hello");
    // string
    
    const b = identity(100);
    // number
    

    Związek pomiędzy wejściem a wyjściem pozostaje nienaruszony:

    Input T
      │
      ▼
    Function<T>
      │
      ▼
    Output T
    

    Generiki umożliwiają również ponowne użycie wspólnych umów. Jeden kopertę odpowiedzi:

    interface ApiResponse<T> {
      data: T;
      status: number;
      message: string;
    }
    

    opisuje wiele ładunków:

    type UserResponse =
      ApiResponse<User>;
    
    type ProductResponse =
      ApiResponse<Product>;
    

    Ograniczenia: wymóg określenia kształtu

    To nie kompiluje się:

    function getId<T>(item: T) {
      return item.id;
    }
    

    ponieważ nic nie informuje TypeScript, że T ma polе id. Ograniczenie dodaje tę gwarancję:

    function getId<T extends { id: string }>(
      item: T
    ) {
      return item.id;
    }
    

    Przyjmowany jest każdy obiekt z łańcuchem id, włączając dodatkowe pola:

    getId({
      id: "123",
      name: "Hareesh"
    });
    

    Przeczytaj to ograniczenie w ten sposób:

    T can be anything
    BUT
    T must have id: string
    

    keyof i dostęp indeksowany

    Dane jest interfejs:

    interface User {
      id: string;
      name: string;
      age: number;
    }
    

    keyof zwraca zbiór nazw swoich właściwości:

    type UserKey = keyof User;
    
    "id" | "name" | "age"
    

    Łączenie keyof z drugim parametrem typu oraz typem dostępu przez indeks T[K] daje dostępca, którego typ zwracanych wartości odpowiada klawiszowi:

    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");
    

    Prawdziwy klucz kompiluje:

    getProperty(user, "name");
    

    Brakujący klucz to błąd kompilacji:

    getProperty(user, "salary");
    

    Siła pochodzi od trzech narzędzi działających razem:

    Generics
       +
    keyof
       +
    Indexed Access
    

    Typy mapowane: transformacja istniejącego typu

    Typy mapowane przetwarzają klucze typu w celu utworzenia nowego typu. Początek od:

    interface User {
      id: string;
      name: string;
      email: string;
    }
    

    Można stworzyć wersję z wyłącznie opcjonalnymi elementami:

    type OptionalUser = {
      [K in keyof User]?: User[K];
    };
    

    konceptualnie równoznaczne z pisaniem:

    {
      id?: string;
      name?: string;
      email?: string;
    }
    

    W ten sposób definiowane są wbudowane elementy takie jak Partial.

    Typy warunkowe: decyzje na poziomie typu

    Typy warunkowe wybierają między dwoma typami na podstawie możliwości przypisania:

    T extends U ? X : Y
    

    To rozpakowuje typy elementów tablicy i pozostawia wszystko inne nietknięte:

    type Flatten<T> =
      T extends Array<infer U>
        ? U
        : T;
    
    type A = Flatten<string[]>;
    // string
    
    type B = Flatten<number>;
    // number
    

    W tym momencie system typów zachowuje się jak mały język kompilowany w czasie kompilacji, co wymaga umiaru.

    infer: wyodrębnienie części typu

    Wewnątrz typu warunkowego infer deklaruje zmienną typu, którą TypeScript wypełnia poprzez dopasowanie. To ponownie implementuje wbudowany ReturnType:

    type MyReturnType<T> =
      T extends (...args: any[]) => infer R
        ? R
        : never;
    

    Łącz go z typeof, aby uzyskać typ z istniejącej funkcji, dzięki czemu nigdy nie odbiegnie on od jej implementacji:

    function getUser() {
      return {
        id: "1",
        name: "Hareesh"
      };
    }
    
    type User = MyReturnType<typeof getUser>;
    

    Definicje typów bibliotecznych w dużej mierze od tego zależą.

    Najpierw wbudowane typy pomocnicze

    Zanim napiszesz sprytnych pomocników, dowiedz się, co jest dostępne w języku:

    Partial
    Required
    Readonly
    Pick
    Omit
    Record
    Exclude
    Extract
    NonNullable
    ReturnType
    Parameters
    InstanceType
    Awaited
    

    Usunięcie pola wrażliwego, na przykład jednej linii zamiast zduplikowanej interfejsu, które może doprowadzić do braku synchronizacji:

    interface User {
      id: string;
      name: string;
      email: string;
      password: string;
    }
    
    type PublicUser =
      Omit<User, "password">;
    

    Przewodnik po wbudowanych typach pomocniczych TypeScript omawia każdy z nich w szczegółowy sposób.

    Record z zamkniętym zestawem kluczy

    Record nadaje się do wyszukiwania i konfiguracji, szczególnie gdy klucze pochodzą z łączenia literów:

    type Permission =
      "read" |
      "write" |
      "delete";
    
    type PermissionMap =
      Record<Permission, boolean>;
    
    const permissions: PermissionMap = {
      read: true,
      write: false,
      delete: false
    };
    

    Jeśli pominie się jakieś uprawnienie, TypeScript zgłasza brakującą kluczową wartość. Typ kluczowy o szerokim zakresie traci tę gwarancję:

    const permissions: Record<string, boolean>
    

    ponieważ Record<string, boolean> przyjmuje praktycznie każdy klucz typu string, pominięcia i błędy pisarskie pozostają niezauważone.

    Bezpieczeństwo typów dla identyfikatorów i niepewnych danych

    Typy specjalne dla identyfikatorów domeny

    Dwa identyfikatory mogą być oba stringami, a mimo to oznaczać różne rzeczy:

    const userId: string;
    const productId: string;
    

    Strukturalnie TypeScript nie może ich odróżnić. Przekrojenie typu string z wymyśloną właściwością tworzy różne typy:

    type UserId =
      string & {
        readonly __brand: "UserId";
      };
    
    type ProductId =
      string & {
        readonly __brand: "ProductId";
      };
    

    Funkcje mogą wtedy żądać odpowiedniego rodzaju identyfikatora:

    function getUser(id: UserId) {}
    
    function getProduct(id: ProductId) {}
    

    Podanie ProductId w miejscu, gdzie oczekiwany jest UserId, powoduje błąd kompilacji. Marka nigdy nie istnieje w czasie wykonywania; wartości związane z marką tworzy się za pomocą małego konstruktora lub funkcji walidacji, która dokonuje przekształcenia w jednym miejscu. Koncepcyjnie:

    string
      │
      ├── UserId
      ├── ProductId
      ├── OrderId
      └── TransactionId
    

    To ma znaczenie w dużych systemach, gdzie dziesiątki identyfikatorów dzieli jeden typ prymitywny.

    Strażnicy typów dla danych unknown

    Dane z sieci, pamięci lub wprowadzone przez użytkownika powinny trafiać jako unknown:

    const data: unknown = await response.json();
    

    Użycie kostiumu to kusząca skrótka:

    const user = data as User;
    

    Zamiast tego zawęż wartość za pomocą użytkowniczego typu strażnika; jego typ zwracany value is User informuje kompilator o tym, co wynik true potwierdza:

    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);
    }
    

    Pamiętaj, że typy są niszczone w czasie wykonywania. Interfejs taki jak ten:

    interface User {
      id: string;
    }
    

    Nie weryfikuje nic z tego, co wysyła API. Powyższy mechanizm ochronny sprawdza jedynie istnienie kluczy, a nie ich typy, dlatego w przypadku niepewnych danych wejściowych należy połączyć TypeScript z walidatorem schematu w czasie wykonywania.

    Wszelkstronna weryfikacja za pomocą never

    Jedna z najskuteczniejszych kombinacji w tym języku:

    Discriminated Union
            +
    never
            +
    switch
    

    Weź związek stanu:

    type Status =
      | "loading"
      | "success"
      | "error";
    

    i pomocnik, który przyjmuje tylko never:

    function assertNever(
      value: never
    ): never {
      throw new Error(
        `Unexpected value: ${value}`
      );
    }
    

    Gdy wszystkie elementy zostały obsłużone, domyślna gałąź ma typ never, więc wywołanie kompiluje się:

    function render(status: Status) {
      switch (status) {
        case "loading":
          return "Loading";
        case "success":
          return "Success";
        case "error":
          return "Error";
        default:
          return assertNever(status);
      }
    }
    

    Teraz kolega z zespołu rozszerza związek:

    Now imagine someone adds:"cancelled" to Status.
    

    Domyślna gałąź otrzymuje wartość "cancelled", która nie może być przypisana do never, w wyniku czego budowa zawodzi, dopóki sprawa nie zostanie rozwiązana. Kompilator staje się wtedy recenzentem projektu, który pamięta o każdym konsumentze.

    Typy literałów szablonowych

    TypeScript może tworzyć typy literałów ciągów znaków na podstawie innych typów:

    type Entity =
      "user" |
      "order" |
      "product";
    
    type Event =
      `${Entity}:created` |
      `${Entity}:updated` |
      `${Entity}:deleted`;
    

    Wynikowy zbiór zawiera każdą kombinację:

    user:created
    user:updated
    user:deleted
    
    order:created
    order:updated
    order:deleted
    
    product:created
    product:updated
    product:deleted
    

    Praktyczne zastosowania obejmują:

    • nazwy wydarzeń
    • klucze wydarzeń analitycznych
    • ciągi uprawnień
    • wzory tras
    • nazwy flag funkcjonalnych
    • tokeny systemu projektowego

    as const gdy wartości stanowią źródło prawdy

    Literał tablicy składający się z ciągów znaków jest rozszerzany:

    const roles = [
      "admin",
      "editor",
      "viewer"
    ];
    

    Jego typ to string[]. Dodanie as const tworzy niezmienne tuple literek:

    const roles = [
      "admin",
      "editor",
      "viewer"
    ] as const;
    

    z którego bezpośrednio wynika typ związku:

    type Role =
      typeof roles[number];
    
    becomes:
    
    
    "admin" |
    "editor" |
    "viewer"
    

    Określ wartości raz i nigdy nie utrzymuj ręcznie równoległej unii.

    satisfies dla sprawdzonej konfiguracji

    satisfies weryfikuje wyrażenie pod kątem typu, nie rozszerzając go do tego typu:

    type Config = {
      retries: number;
      environment:
        | "development"
        | "production";
    };
    
    const config = {
      retries: 3,
      environment: "production"
    } satisfies Config;
    

    Obiekt jest sprawdzany w odniesieniu do Config, jednak config.environment zachowuje dosłowny typ "production", który zwykła adnotacja straciłaby. Nadaje się do:

    • konfiguracji tras
    • flag funkcjonalności
    • tokenów projektowych
    • obiektów ustawień statycznych
    • map uprawnień

    Injekcja zależności bez frameworka

    Tworzenie zależności wewnątrz klasy łączy ją z nimi:

    class UserService {
      private api = new ApiClient();
    }
    

    Otrzymywanie ich za pośrednictwem konstruktora sprawia, że klasa pozostaje agnostyczna:

    class UserService {
      constructor(
        private readonly api: ApiClient
      ) {}
    }
    

    Produkcja trafia do rzeczywistego klienta:

    const service =
      new UserService(apiClient);
    

    i testuje mocka:

    const service =
      new UserService(mockApiClient);
    
                    UserService
                          ▲
                          │
                     Dependency
                          │
                 ┌────────┴────────┐
                 ▼                 ▼
            ApiClient         MockApiClient
           Production            Testing
    

    Jest to jeden z najtańszych sposobów na stworzenie kodu poddawanego testom, a wszystko, czego potrzeba, to parametry konstruktora.

    Rozróżnianie podobnych wzorców

    Wzorzec stanu czy zjednoczenie z dyskryminacją?

    Biorąc pod uwagę cykl życia zamówienia:

    Draft
    Paid
    Shipped
    Cancelled
    

    Unia dyskryminująca może być wszystkim, czego potrzebujesz:

    type Order =
      | { status: "draft" }
      | { status: "paid" }
      | { status: "shipped" }
      | { status: "cancelled" };
    

    Ale gdy każdy stan ma znaczące zachowanie:

    Draft
     ├── edit()
     ├── submit()
    
    Paid
     ├── refund()
     ├── ship()
    Shipped
     ├── track()
     └── deliver()
    

    Wzorzec State, w którym każdy obiekt stanu implementuje dozwolone operacje, może być lepszym rozwiązaniem. Decyduj na podstawie stopnia złożoności każdego stanu, a nie terminologii.

    Strategia czy State?

    Częsty temat na rozmowach kwalifikacyjnych. W przypadku wzorca Strategia to klient wybiera algorytm:

    Order
      │
      ▼
    Strategy
      ├── CreditCard
      ├── PayPal
      └── UPI
    

    Wraz z State obiekt zmienia swoje zachowanie w miarę przechodzenia przez swój cykl życia:

    Order
      │
      ├── Draft
      ├── Paid
      └── Shipped
    

    Strategia określa sposób wykonywania zadania; stan określa, co dany obiekt robi na danym etapie.

    Fabryka czy strategia?

    Fabryka odpowiada na pytanie „który obiekt powinien zostać stworzony?”:

    PaymentFactory.create("stripe");
    

    Strategia odpowiada na pytanie „jakie zachowanie powinno zostać wykonywane?”:

    new Order(discountStrategy);
    

    Łączą się naturalnie – fabryka wytwarza strategię, która wykonywa pracę:

    Factory
       ↓
    creates
       ↓
    Strategy
       ↓
    executes behavior
    

    Zastosowanie wzorców w React

    Propozycje typu variant zamiast flag boolowskich

    Niezależne wartości boolowskie mogą prowadzić do absurdów, takich jak przycisk, który jest jednocześnie główny i oznaczający niebezpieczeństwo:

    interface ButtonProps {
      primary?: boolean;
      danger?: boolean;
      loading?: boolean;
    }
    

    Związek wariantów pozwala każdemu z nich określić własne wymagania:

    type ButtonProps =
      | {
          variant: "primary";
          loading?: boolean;
        }
      | {
          variant: "danger";
          confirmationRequired: boolean;
        };
    

    Wariant niebezpieczny musi teraz określić confirmationRequired.

    API komponentów, które odrzucają sprzeczności

    Komponent, który renderuje albo link, albo przycisk z opcjonalnymi atrybutami href i onClick, mógłby dopuścić oba lub żaden z nich. Poniższy fragment pokazuje wersję luźną, wersję z rozróżnieniem oraz prawidłowe zastosowanie:

    {
      href?: string;
      onClick?: () => void;
    }
    
    use:
    type ActionProps =
      | {
          type: "link";
          href: string;
        }
      | {
          type: "button";
          onClick: () => void;
        };
    
    
    Now:
    
    <Action
      type="link"
      href="/users"
    />
    

    Odrzuca się wariant linku, w którym zamiast href podano obsługę kliknięcia:

    <Action
      type="link"
      onClick={...}
    />
    

    Obsługiwane tryby, przedstawione w prosty sposób:

    Link
    Button
    

    TypeScript jest teraz częścią architektury komponentów, a nie dokumentacją, która szybko staje się przestarzała.

    Bus zdarzeń z bezpieczeństwem typów

    Zacznij od mapy łączącej nazwy zdarzeń z typami danych:

    type Events = {
      "user:created": User;
      "user:deleted": UserId;
      "order:created": Order;
    };
    

    Generyczny obiekt typu bus na tym mapie łączy każdą nazwę z jej treścią za pomocą keyof i 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
      }
    }
    

    Prawidłowy payload kompiluje się:

    bus.emit("user:created", user);
    

    Nieprawidłowy nie zawiera:

    bus.emit("user:created", order);
    

    Tutaj znajduje się kilka narzędzi:

    Generics
    +
    keyof
    +
    Indexed Access
    +
    Mapped Type thinking
    

    Typy w całej warstwie API

    Zrozwinięty interfejs użytkownika często organizuje dostęp do danych w ten sposób:

    Component
         ↓
    Hook
         ↓
    Service
         ↓
    Repository
         ↓
    API Client
         ↓
    HTTP
    

    przy czym typy są zachowywane na każdym kroku. Repozytorium może przyjąć identyfikatory z marką i zwrócić Result:

    type ApiResponse<T> = {
      data: T;
      status: number;
    };
    
    interface UserRepository {
      getUser(id: UserId):
        Promise<Result<User, ApiError>>;
    }
    

    Komponent nie zajmuje się już tym na każdej warstwie:

    any
    

    Samych podpisów wystarczy, by wiedzieć, co może się udać, co może się nie udać oraz z jakimi typami.

    Antypaternale, których należy unikać

    any wszędzie

    function process(data: any) {}
    

    Parametr any wyłącza sprawdzanie wszystkiego, czego dotyka. Lepiej używać:

    function process(data: unknown) {}
    

    i zwęża się przed użyciem.

    Stwierdzenia jako walidacja

    const user =
      response.data as User;
    

    Przekształcenie typu as nic nie sprawdza; jedynie prosi kompilator o zaufanie do Ciebie. Waliduj dane niewiarygodne w czasie wykonywania.

    Generyki dla samej przyjemności

    Unikaj takich sygnatur jak:

    function transform<
      T,
      U,
      V,
      R
    >(...) {}
    

    chyba że każdy parametr typu wyraża rzeczywisty związek. Parametr typu użyty tylko raz jest zazwyczaj niepotrzebny.

    Olbrzymie unie

    Unia z dyskryminacją składająca się ze stu elementów jest trudna do nawigowania i rozwijania. W takim przypadku może być potrzebna inna abstrakcja, np. zagnieżdżone unie lub przeniesienie różnic do samych danych.

    Genialne rozwiązania na poziomie typów

    Gdy typ jest trudniejszy do zrozumienia niż logika biznesowa, którą opisuje, cofnij się o krok:

    Type complexity
           │
           ▼
    Developer complexity
           │
           ▼
    Maintenance cost
    

    Bezpieczeństwo typów ma swoją cenę. Staraj się osiągnąć największą użyteczność bezpieczeństwa na jednostkę złożoności, a nie najbardziej zaawansowane typy.

    Drzewo decyzyjne do wyboru

    To drzewo łączy typowe wyzwania z możliwymi rozwiązaniami:

    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
    

    Traktuj to jako punkt wyjścia do dyskusji; zasada „zaczynaj od prostoty” nadal obowiązuje na każdym poziomie.

    Co należy najpierw nauczyć

    Dla inżynierów front-end przygotowujących się do stanowisk kierowniczych lub senioralnych zapamiętywanie wszystkich wzorców Gang of Four to niewydajne wykorzystanie czasu. Praktyczna kolejność:

    Podstawy:

    Discriminated Unions
    Generics
    Type Guards
    keyof
    Mapped Types
    Utility Types
    Composition
    Strategy
    Repository
    Factory
    

    Zdecydowanie polecam dalej:

    Result Type
    Branded Types
    Conditional Types
    infer
    Exhaustive Checking
    Dependency Injection
    Adapter
    Facade
    Observer
    Decorator
    

    Warto zrozumieć to koncepcyjnie:

    Builder
    Command
    State
    Abstract Factory
    Singleton
    

    Kodowanie interfejsu użytkownika na co dzień w dużej mierze opiera się na tej kombinacji:

    Generics
    +
    Discriminated Unions
    +
    Composition
    +
    Strategy
    +
    Repository
    

    lepiej niż w jakiejkolwiek podręcznikowej implementacji Abstrakcyjnej Fabryki.

    Jak ocena zmienia się wraz z doświadczeniem

    Na początku inżynierowie pytają: „Który wzorzec powinienem tu użyć?”. Z doświadczeniem w pracy nad dużymi systemami pytanie to zmienia się na: „Jaka jest najmniejsza abstrakcja, która rozwiązuje ten problem bez utrudniania zrozumienia systemu?”. Proces pracy oparty na wzorcach wygląda w ten sposób:

    Problem
      ↓
    Pattern
      ↓
    More classes
      ↓
    More abstractions
    

    Proces pracy skupiony na problemie wygląda w ten sposób:

    Problem
      ↓
    Understand volatility
      ↓
    Identify boundary
      ↓
    Start simple
      ↓
    Introduce abstraction only where repetition/change justifies it
    

    Dobre wzorce pojawiają się w momencie, gdy stają się jasne wyzwania związane z architekturą; nie są one narzucane z góry.

    Pytania z wywiadu sprawdzające prawdziwe zrozumienie

    „Co to jest wzorzec Factory?” sprawdza pamięć. Te pytania natomiast oceniają umiejętność osądu.

    Architektura:

    1. W obliczu komponentu React składającego się z 2000 linii kodu, jak decydujesz, jaką abstrakcję wprowadzić?
    2. Kiedy odmówiłbyś użycia wzorca, który wydaje się odpowiedni?
    3. Jak można stwierdzić, że abstrakcja jest przedwczesna?
    4. Kiedy kompozycja jest lepsza od dziedziczenia?
    5. Jak zapobiec temu, by Singleton stał się globalnym, zmienialnym stanem?

    Podstawy TypeScript:

    1. Jak zamodelować żądanie z stanami ładowania, sukcesu, błędu i ponownej próby?
    2. Jak zapobiec nieprawidłowym kombinacjom właściwości React?
    3. W czym różnią się unknown, any i never?
    4. Kiedy wybrać type zamiast interface i odwrotnie?
    5. W jaki sposób keyof współdziała z generykami?

    Zaawansowany TypeScript:

    1. Jaki jest konkretny przypadek użycia typów warunkowych?
    2. Czym zajmuje się infer?
    3. Co to jest typ mapowany?
    4. Co to są typy warunkowe dystrybutywne?
    5. Kiedy typy markowane są przydatne?
    6. Jaki problem rozwiązuje satisfies?
    7. W jaki sposób as const zmienia proces wnioskowania?

    Projektowanie w praktyce:

    1. Zaprojektuj bezpieczny pod względem typów bus zdarzeń.
    2. Zaprojektuj bezpiecznego pod względem typów klienta API.
    3. Zaprojektuj system uprawnień przy użyciu TypeScript.
    4. Zaprojektuj abstrakcję płatności obsługującą Stripe, PayPal oraz innego dostawcę.
    5. Jak dodać warstwę Repository do istniejącego aplikacji React bez jej przepisywania?
    6. Jak definiować wydarzenia WebSocket z różnymi treściami?
    7. Jak zapobiec przekazywaniu ProductId tam, gdzie wymagany jest UserId?

    Jedno pytanie ujawniające prawdę: „Opisz sytuację, gdy celowo zdecydowałeś się nie używać wzorca projektowego”. Dobra odpowiedź wyjaśnia kompromis: przy pojedynczej implementacji i braku prawdopodobnych zmian, pośrednictwo nie zmniejszyłoby złożoności, więc kod pozostał prosty, dopóki drugi przypadek użycia tego nie uzasadnił.

    Ostateczny model myślowy

    Zacznij od problemu biznesowego, znajdź źródło złożoności, oddziel zmiany behawioralne od strukturalnych, a następnie pozwól bezpieczeństwu typów udoskonalić wynik.

                     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
    

    Wzory jako wspólna słownictwo

    Wzory działają najlepiej jako wspólne słownictwo: „to jest adapter”, „te zachowania są wymienne”, „te stany należą do jedności”, „oznacz te ID” oraz, co najważniejsze, „ta abstrakcja jeszcze nie zasłużyła na swoją złożoność”. Dobry TypeScript ocenia się po tym, czy system posiada te właściwości, a nie po tym, jak sprytnie są zaprojektowane jego typy:

    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
    
    • Wybieraj wzorce ze względu na stopień złożoności, jaki eliminują, a nie ze względu na ich powszechność.
    • Wolij unie, typy Result oraz wyczerpujące sprawdzenia zamiast pól opcjonalnych i nadziei.
    • Waliduj dane na granicach czasu wykonywania; same typy nie zapewnią ci tam ochrony.
    • Wybieraj najprostszą abstrakcję, która radzi sobie ze zmianami, których faktycznie oczekujesz.

    Literatura pokrewna

  • Wzory projektowe React: Od klasycznego OOP do nowoczesnych hooksów: Wyjaśnia, w jaki sposób klasyczne wzory programistyczne takie jak Singleton, Factory i Observer znajdują zastosowanie w React, a także wzory specyficzne dla Reacta, takie jak HOC-y, Hooks i składane komponenty.