Wzorce projektowe w TypeScript: kiedy pomagają, a kiedy komplikują kod

0
110
2.7/5 - (3 votes)

Nawigacja:

Po co w ogóle wzorce projektowe w TypeScript?

Skąd wzięły się wzorce projektowe i co mają wspólnego z TypeScriptem

Wzorce projektowe wywodzą się głównie z obiektowego świata Javy i C++ (słynne „Gang of Four”). Opisywały powtarzalne sposoby rozwiązywania znanych problemów architektonicznych: jak tworzyć obiekty, jak je łączyć, jak organizować przepływ logiki. Ich celem nigdy nie było „upiększanie” kodu ani zdobywanie punktów na rozmowie kwalifikacyjnej, tylko ułatwienie komunikacji i utrzymania dużych systemów.

TypeScript jest nadzbiorem JavaScriptu, który historycznie był językiem prototypowym, funkcyjnym, dynamicznym. Dopiero z czasem doszły klasy, moduły ES i mechanizmy bardziej „enterprise’owe”. Dlatego klasyczne wzorce projektowe trzeba w TS traktować z lekkim dystansem: część z nich ma świetne zastosowanie, część można zastąpić prostszymi technikami funkcyjnymi.

Istotna różnica polega na tym, że w Java/C# wzorce opierają się „twardo” na klasach i dziedziczeniu. W TypeScripcie mamy:

  • typowanie strukturalne – liczy się kształt typu, nie jego nazwa,
  • łatwość tworzenia funkcji wyższego rzędu,
  • moduły ES jako podstawowy mechanizm hermetyzacji,
  • typy unijne i przecięcia, które pozwalają rozwiązać sporo problemów bez rozbudowanej hierarchii klas.

To wszystko sprawia, że klasyczne „książkowe” wzorce trzeba zaadaptować do realiów TS, zamiast przepisywać je 1:1 z przykładów w Javie.

Wzorce jako język komunikacji w zespole

Największa wartość wzorców w TypeScript nie leży w samej strukturze kodu, tylko w tym, że pełnią rolę skrótu myślowego. Gdy ktoś mówi: „Zróbmy tu Strategy zamiast tego potworka z switch”, to druga osoba wie, że chodzi o:

  • wydzielenie algorytmu do osobnych jednostek,
  • ujednolicenie interfejsu,
  • możliwość podmiany konkretnej implementacji.

Zamiast tłumaczyć na pięć akapitów, wystarczy nazwa wzorca. To działa, pod warunkiem że zespół ma wspólne zrozumienie, a wzorce są używane świadomie, a nie jako ozdoba.

W świecie TypeScriptu, gdzie projekty bywają rozwijane przez rozproszone zespoły, wzorce projektowe są po prostu elementem wspólnego słownika – tak samo jak „DTO”, „layered architecture” czy „CQRS”.

Jak typowanie w TypeScript zmienia podejście do wzorców

TypeScript dodaje do JavaScriptu bogaty system typów: generyki, typy warunkowe, mapped types, unie, przecięcia. Dla wzorców projektowych oznacza to dwa skutki:

  1. Można uprościć wiele klasycznych struktur – np. zamiast rozbudowanej hierarchii klas stosujesz proste typy funkcyjne i unie.
  2. Można mocniej chronić kontrakty – interfejsy i generyki pilnują, żeby adapter zwracał właściwy kształt, fabryka nie mieszała typów, a strategia przyjmowała prawidłowe parametry.

Przykładowo, zamiast robić osobną hierarchię „Command” z abstrakcyjną klasą, często wystarczy:

type Command<TInput, TOutput> = (input: TInput) => Promise<TOutput>;

const createUser: Command<CreateUserDTO, User> = async (input) => {
  // ...
};

const sendEmail: Command<EmailPayload, void> = async (payload) => {
  // ...
};

Logicznie jest to bardzo bliskie wzorcowi Command, ale zrealizowane ergonomicznie w duchu TS/JS, bez wymyślania klas i dziedziczenia, gdy nie są potrzebne.

Kiedy wzorzec pomaga, a kiedy wystarczy prosty kod?

Najważniejsze kryteria: kiedy sięgać po wzorzec

Wzorce projektowe w TypeScript mają sens, gdy rozwiązują realny ból. Kilka praktycznych kryteriów, które pomagają w decyzji:

  • Złożoność domeny – im bardziej skomplikowane reguły biznesowe, tym większa szansa, że wzorzec (np. Strategy, Observer, Facade) pomoże ogarnąć chaos.
  • Spodziewane zmiany – jeśli wiadomo, że logika będzie się rozrastać lub często zmieniać, lepiej zainwestować w strukturę, która przyjmie te zmiany bez bólu.
  • Wielkość i rotacja zespołu – im więcej osób grzebie w kodzie, tym ważniejsze jest posiadanie czytelnych wzorców i kontraktów.
  • Długość życia projektu – MVP na 2 miesiące zniesie więcej prostoty (a nawet bałaganu) niż system pisany na lata.

Jeśli tworzysz mały, jednorazowy skrypt w Node.js, robienie pięcio­warstwowej architektury z Abstract Factory i Command Bus jest lekką przesadą. W aplikacji SaaS rozwijanej przez lata – już niekoniecznie.

Prosty przykład: fabryka jako przerost formy vs. realna potrzeba

Wyobraźmy sobie mini-aplikację do generowania raportów w przeglądarce, która ma dwa tryby uruchomienia: lokalny (mock) i produkcyjny (realne API). Można tu pójść dwiema drogami.

Wersja przesadnie „wzorcowa”:

interface ApiClient {
  getReport(id: string): Promise<Report>;
}

class MockApiClient implements ApiClient {
  async getReport(id: string) { /* ... */ }
}

class RealApiClient implements ApiClient {
  async getReport(id: string) { /* ... */ }
}

class ApiClientFactory {
  static create(env: 'mock' | 'prod'): ApiClient {
    if (env === 'mock') return new MockApiClient();
    return new RealApiClient();
  }
}

A teraz prostsza wersja, która w małym projekcie spokojnie wystarczy:

type ApiClient = {
  getReport(id: string): Promise<Report>;
};

const createApiClient = (env: 'mock' | 'prod'): ApiClient => {
  if (env === 'mock') {
    return {
      async getReport(id) { /* ... */ },
    };
  }

  return {
    async getReport(id) { /* ... */ },
  };
};

Logika jest ta sama. Różnica polega na tym, że przy małej aplikacji pierwsza wersja wprowadza zbędną ceremonię i rozproszenie kodu. Przy dużym systemie pluginów, gdzie instancji i wariantów jest dużo więcej, rozdzielenie na klasy i interfejsy może jednak znacząco poprawić czytelność.

Jak rozpoznać „wzorzec dla wzorca”

Kilka sygnałów ostrzegawczych wskazuje, że wzorce są używane głównie „dla sportu”:

  • Obecne są skomplikowane klasy i interfejsy, które mają tylko po jednej implementacji, a i tak nikt ich nie planuje wymieniać.
  • Większość metod i tak robi proste wywołanie jednej funkcji z innego modułu.
  • Nowi programiści spędzają godziny na śledzeniu, gdzie „naprawdę” coś się dzieje, bo wszystko jest „owrapowane” przez dwa-trzy wzorce.
  • Zmiana wymagania biznesowego wymaga dotknięcia pięciu klas wzorcowych, zamiast jednej prostej funkcji.

Jeżeli w trakcie code review najczęściej pada pytanie „po co tu ten wzorzec?”, to znaczy, że gdzieś został przekroczony próg użyteczności. Czasem lepiej usunąć jedną warstwę abstrakcji niż dopisywać kolejną.

Zasada: zacznij prosto, wzorzec wprowadź przy refaktoryzacji

Praktyczny sposób pracy z wzorcami w TypeScript:

  1. Rozwiązaj problem możliwie najprościej (nawet prostym if, jedną klasą czy funkcją).
  2. Obserwuj, jak kod rośnie: gdzie się duplikuje, gdzie się rozlewa switch, które moduły są „tłuste”.
  3. W momencie, gdy pojawi się realny ból (trudno dodać nowy wariant, testy są uciążliwe), wybierz wzorzec, który atakuje konkretny problem.
  4. Przeprowadź refaktoryzację, korzystając z testów jako asekuracji.

Takie podejście ma dwie zalety: nie generuje architektury „na wszelki wypadek”, a jednocześnie uczy, gdzie wzorce projektowe faktycznie przynoszą ulgę. Po kilku takich cyklach intuicja co do momentu „teraz wzorzec ma sens” staje się dużo lepsza.

Programista w okularach pracuje nad kodem TypeScript w biurze
Źródło: Pexels | Autor: cang hai

Kontekst TypeScriptu: cechy języka, które wpływają na wzorce

Klasy, interfejsy, unie i generyki – inne fundamenty niż w Javie

TypeScript obsługuje klasy i interfejsy, ale pod spodem nadal działa JavaScript. Kilka kluczowych cech wpływa na to, jak implementować wzorce:

  • Typowanie strukturalne – obiekt pasuje do interfejsu, jeśli ma wymagane pola, niezależnie od pochodzenia. Interfejs jest opisem kształtu, a nie deklaracją „wywodzenia się z”.
  • Unie typów – zamiast dziedziczenia często wystarcza type Shape = Circle | Rectangle i sprawdzanie kind.
  • Generyki – fabryki, repozytoria czy strategie można elegancko typować generycznie, bez duplikowania kodu dla różnych typów domenowych.

Przykład: zamiast klasycznego Abstract Factory dla repozytoriów można zastosować funkcję generyczną:

interface Repository<T> {
  findById(id: string): Promise<T | null>;
  save(entity: T): Promise<void>;
}

const createInMemoryRepository = <T extends { id: string }>(): Repository<T> => {
  const store = new Map<string, T>();
  return {
    async findById(id) {
      return store.get(id) ?? null;
    },
    async save(entity) {
      store.set(entity.id, entity);
    }
  };
};

Strukturalne typowanie sprawia, że nie trzeba budować całego drzewa dziedziczenia – wystarczy, że T ma id. To mocno upraszcza część obiektowych wzorców.

Funkcje wyższego rzędu jako alternatywa dla wielu wzorców

JavaScript, a więc i TypeScript, ma funkcje pierwszej klasy. Można je przekazywać jako argumenty, zwracać z innych funkcji, komponować. Dzięki temu wiele klasycznych wzorców da się zapisać w prostszej, funkcyjnej formie:

  • Strategy – funkcja przyjmująca inny „algorytm” jako parametr.
  • Decorator – funkcja opakowująca inną funkcję (middleware, logowanie, retry).
  • Command – po prostu funkcja z określonym sygnaturą, przechowywana w mapie.

Na przykład wzorzec Strategy w wersji funkcyjnej:

type PricingStrategy = (basePrice: number) => number;

const defaultPricing: PricingStrategy = (base) => base;
const promoPricing: PricingStrategy = (base) => base * 0.8;

const calculatePrice = (basePrice: number, strategy: PricingStrategy) => {
  return strategy(basePrice);
};

Nie ma klas, dziedziczenia ani skomplikowanych interfejsów. Jest za to jasny kontrakt typowy i możliwość łatwej podmiany implementacji.

Moduły ES jako naturalny „Singleton” i Facade

Kiedyś wzorzec Singleton powstał m.in. po to, by mieć jedną globalną instancję klasy. W świecie modułów ES rola ta jest często przejmowana przez sam moduł:

// logger.ts
let level: 'info' | 'debug' | 'error' = 'info';

export const setLevel = (newLevel: typeof level) => {
  level = newLevel;
};

export const log = (message: string) => {
  if (level !== 'error') {
    console.log(message);
  }
};

Ten moduł zachowuje stan (poziom logowania) i zapewnia jeden „punkt dostępu” – dokładnie jak Singleton. Nie trzeba jednak żadnych klas, blokowania konstruktorów ani „magicznych” statycznych instancji. Wystarczy mechanizm importów.

Podobnie wzorzec Facade często wyraża się w TypeScripcie po prostu jako moduł eksportujący uproszczony zestaw funkcji, który wewnętrznie łączy się z kilkoma złożonymi podsystemami. Potrzeba jest ta sama, ale „opakowanie” jest bliższe idiomom JS/TS niż książkowa wersja dla C++.

Typy strukturalne a stosowanie interfejsów we wzorcach

W językach nominalnie typowanych (Java, C#) implementacja interfejsu jest deklaracją w kodzie klasy. W TypeScripcie liczy się wyłącznie to, czy obiekt pasuje do interfejsu. To daje ciekawy efekt: można używać interfejsów do wzorców adaptera, strategii czy fabryk nawet wtedy, gdy nie kontroluje się pierwotnej definicji typu.

Przykład adaptera dla klienta HTTP:

interface HttpClient {
  get<T>(url: string): Promise<T>;
  post<T>(url: string, body: unknown): Promise<T>;
}

const axiosAdapter: HttpClient = {
  async get(url) {
    const res = await axios.get(url);
    return res.data;
  },
  async post(url, body) {
    const res = await axios.post(url, body);
    return res.data;
  }
};

Tu axiosAdapter pasuje do interfejsu HttpClient bez żadnego „oficjalnego” implements.

Przeciążenia i inferencja typów a projekt interfejsów

TypeScript ma coś, czego w wielu obiektowych językach brakuje: bardzo sprytną inferencję typów i przeciążenia funkcji na poziomie typów, nie implementacji. To w praktyce zmienia sposób projektowania API i czasem całkowicie eliminuje potrzebę książkowego wzorca.

Dobry przykład to API do wyszukiwania użytkowników. Klasyczne podejście obiektowe mogłoby zakładać osobne strategie filtrowania, a w TypeScripcie często wystarczy dopracowana sygnatura:

interface User {
  id: string;
  email: string;
  isActive: boolean;
}

type UserFilter =
  | { by: 'id'; id: string }
  | { by: 'email'; email: string }
  | { by: 'active'; active: boolean };

function findUser(filter: { by: 'id'; id: string }): Promise<User | null>;
function findUser(filter: { by: 'email'; email: string }): Promise<User | null>;
function findUser(filter: { by: 'active'; active: boolean }): Promise<User[]>;
async function findUser(filter: UserFilter): Promise<User | User[] | null> {
  // implementacja technicznie jedna, typowo trzy warianty
}

W językach z przeciążeniem metod często szłoby to w kierunku klas Strategy + różne implementacje. Tutaj jedna funkcja i parę przeciążeń daje czytelniejsze, typowane API bez wyciągania cięższej artylerii.

Przy implementacji wzorców w TypeScripcie opłaca się więc najpierw zadać pytanie: czy dobry projekt typów (unie, przeciążenia, generyki) nie rozwiąże problemu prościej niż osobny „układ klas”?

Literal types i uniony dyskryminowane jako lekki State/Strategy

Wiele przykładów wzorca State lub Strategy opiera się na drzewie klas. W TypeScripcie tę samą ideę można zapisać w postaci unii dyskryminowanej, zachowując przejrzystość i pełną kontrolę typów przy przełączaniu stanów.

type CheckoutState =
  | { kind: 'idle' }
  | { kind: 'addressFilled'; address: string }
  | { kind: 'paymentSelected'; address: string; method: 'card' | 'blik' }
  | { kind: 'completed'; orderId: string };

const nextState = (state: CheckoutState, event: unknown): CheckoutState => {
  switch (state.kind) {
    case 'idle':
      // ...
    case 'addressFilled':
      // ...
    // itd.
  }
};

Tu zamiast hierarchii klas State + ConcreteState wystarcza dobrze zaprojektowany typ i funkcja przełączająca stany. Tsserver podpowiada wszystkie możliwe warianty kind, więc ryzyko pomyłki jest mniejsze niż przy ręcznym zarządzaniu podtypami klas.

W sytuacjach, gdzie stanów jest dużo, a ich przejścia są złożone (np. połączenia sieciowe z retry, przerwaniami, limitami), klasyczny State może nadal mieć sens – ale wprost widać, że TypeScript oferuje „lżejszą” alternatywę, którą często łatwiej zrozumie zespół frontendowy.

Kluczowe wzorce kreacyjne w TypeScript: kiedy mają sens

Factory / Factory Method – kiedy warto wyjść poza zwykłą funkcję

Fabryki to w TypeScripcie codzienny chleb, tylko że większość z nich wygląda jak zwykłe funkcje. Nie zawsze trzeba robić z tego osobny wzorzec, ale są sytuacje, gdzie wyraźny Factory pomaga:

  • tworzenie obiektów zależnych od środowiska (przeglądarka vs Node, mock vs prod),
  • konfiguracja na podstawie zewnętrznego pliku / zmiennych środowiskowych,
  • tworzenie wielu instancji tej samej „rodziny” obiektów (np. różne implementacje Storage).

Granica między „po prostu funkcją” a „fabryką” jest płynna. Zwykle robi się ciekawie, kiedy pojawia się konfiguracja i zależności, które trzeba wstrzyknąć.

interface StoragePort {
  get(key: string): Promise<string | null>;
  set(key: string, value: string): Promise<void>;
}

type StorageConfig =
  | { driver: 'memory' }
  | { driver: 'redis'; url: string };

const createStorage = (config: StorageConfig): StoragePort => {
  if (config.driver === 'memory') {
    const store = new Map<string, string>();
    return {
      async get(key) { return store.get(key) ?? null; },
      async set(key, value) { store.set(key, value); }
    };
  }

  const client = createRedisClient(config.url);
  return {
    async get(key) { return client.get(key); },
    async set(key, value) { return client.set(key, value); }
  };
};

To już w praktyce jest Simple Factory, choć formalnie to tylko funkcja. Jeśli jednak logika konfiguracji zaczyna się rozrastać (pluginy, rozszerzenia, lazy-loading), wyciągnięcie tego do osobnej klasy fabryki lub modułu konfiguracyjnego może poprawić strukturę kodu.

Builder – czy TypeScript naprawdę go potrzebuje?

Builder powstał w czasach, kiedy brakowało nazwanych parametrów i typów rekordów. W TypeScripcie często wystarczy zwykły obiekt konfiguracyjny, ale bywa, że Builder nadal się przydaje:

  • konfiguracja ma wiele kroków i niektóre z nich są obowiązkowe,
  • wymagane jest „prowadzenie” programisty przez poprawną kolejność wywołań,
  • obiekt wynikowy jest skomplikowany i kosztowny do utworzenia (np. klient do zewnętrznego API z cachingiem, retry itd.).

Przykład „typowego” builera dla klienta HTTP, ale wykorzystującego możliwości TypeScripta:

interface HttpClient {
  get<T>(url: string): Promise<T>;
  post<T>(url: string, body: unknown): Promise<T>;
}

type WithBaseUrl = { baseUrl: string };

class HttpClientBuilder<TState> {
  private baseUrl?: string;
  private timeout = 3000;

  setBaseUrl(url: string): HttpClientBuilder<TState & WithBaseUrl> {
    this.baseUrl = url;
    return this as unknown as HttpClientBuilder<TState & WithBaseUrl>;
  }

  setTimeout(ms: number): this {
    this.timeout = ms;
    return this;
  }

  build(this: HttpClientBuilder<TState & WithBaseUrl>): HttpClient {
    if (!this.baseUrl) {
      throw new Error('Base URL required');
    }
    // tu można użyć this.timeout itd.
    return {
      async get(url) { /* ... */ },
      async post(url, body) { /* ... */ },
    };
  }
}

const client = new HttpClientBuilder<{}>()
  // .build(); // błąd kompilacji, brak baseUrl
  .setBaseUrl('https://api.example.com')
  .setTimeout(5000)
  .build();

Użycie generyków pozwala zakodować w typach, że build() można wywołać dopiero po podaniu baseUrl. W wielu projektach frontendowych wystarczy prosty obiekt z polami opcjonalnymi, ale w bibliotece SDK dla innych zespołów taki dopracowany Builder znacząco zmniejsza liczbę błędów.

Singleton – zwykle „wbudowany” w moduły

Klasyczny Singleton w TypeScripcie (z prywatnym konstruktorem, statyczną instancją itd.) jest potrzebny rzadziej niż się wydaje, bo większość projektów i tak korzysta z modułów ES. Moduł jest ładowany raz i jego top-levelowy stan działa jak globalna instancja.

// config.ts
let currentConfig: AppConfig | null = null;

export const setConfig = (config: AppConfig) => {
  currentConfig = config;
};

export const getConfig = () => {
  if (!currentConfig) {
    throw new Error('Config not initialized');
  }
  return currentConfig;
};

To prostsze niż ręczne pilnowanie jedynej instancji klasy. Jeśli jednak projekt wymaga precyzyjnego kontrolowania cyklu życia (np. wiele instancji tej samej aplikacji w jednym procesie testowym), klasyczna postać Singletona lub wręcz rezygnacja z niego na rzecz jawnego przekazywania zależności będzie rozsądniejsza.

Dependency Injection / Inversion of Control w lekkiej wersji

Rozbudowane kontenery DI z refleksją, automatycznym wykrywaniem zależności i dekoratorami są w świecie TypeScripta możliwe – ale w większości projektów frontendowych są cięższe niż trzeba. Często wystarczy prosty „kompozytor” zależności:

interface Services {
  http: HttpClient;
  storage: StoragePort;
  logger: Logger;
}

const createServices = (): Services => {
  const logger = createLogger();
  const http = createHttpClient({ logger });
  const storage = createStorage({ logger });

  return { http, storage, logger };
};

// gdzieś w kodzie aplikacji
const services = createServices();

Taka prosta kompozycja, czasem nazwana „manual DI”, spełnia główny cel wzorca: rozdziela tworzenie obiektów od ich użycia. Jeśli w przyszłości dojdzie wersja serwerowa, testowa czy e2e – można dodać createTestServices() itp. i dalej nie trzeba pełnego frameworka DI.

Rozbudowane kontenery mają sens przy większych monolitach (np. SSR + API + worker w jednym repo), gdzie liczba zależności jest naprawdę duża. W typowej aplikacji SPA lub narzędziu CLI wystarczy czasem jeden moduł composition-root.ts.

Młody mężczyzna planujący strategię na tablicy w biurze
Źródło: Pexels | Autor: Startup Stock Photos

Wzorce strukturalne w TypeScript: komponowanie zamiast dziedziczenia

Adapter: cienki, strukturalny i często funkcjonalny

Adapter w TS zwykle nie wymaga osobnych klas. Częściej wystarczy obiekt lub funkcja, która „przetłumaczy” API zewnętrznej biblioteki na kształt, jaki rozumie nasz kod.

interface Mailer {
  send(to: string, subject: string, body: string): Promise<void>;
}

const nodemailerAdapter = (transport: Transport): Mailer => ({
  async send(to, subject, body) {
    await transport.sendMail({
      to,
      subject,
      text: body,
    });
  }
});

W tym podejściu zamiast klasy NodemailerAdapter implements Mailer jest funkcja zwracająca obiekt pasujący do interfejsu. Strukturalne typowanie robi resztę. Dopóki interfejs jest prosty i niesprzęgnięty z innymi klasami domenowymi, taka wersja adaptera jest często wygodniejsza niż budowa mini-hierarchii.

Decorator: funkcje opakowujące i middleware

W świecie TypeScripta (i generalnie JS) dekorator bardzo często przyjmuje postać funkcji przyjmującej inną funkcję i zwracającej „opakowaną” wersję. To nadal jest wzorzec Decorator, tylko w formie funkcyjnej.

type Handler<TIn, TOut> = (input: TIn) => Promise<TOut>;

const withLogging = <TIn, TOut>(handler: Handler<TIn, TOut>): Handler<TIn, TOut> => {
  return async (input) => {
    console.log('Input:', input);
    const result = await handler(input);
    console.log('Output:', result);
    return result;
  };
};

const withRetry = <TIn, TOut>(handler: Handler<TIn, TOut>): Handler<TIn, TOut> => {
  return async (input) => {
    for (let attempt = 0; attempt < 3; attempt++) {
      try {
        return await handler(input);
      } catch (e) {
        if (attempt === 2) throw e;
      }
    }
    throw new Error('unreachable');
  };
};

const sendEmail: Handler<EmailInput, void> = async (input) => { /* ... */ };

const robustSender = withLogging(withRetry(sendEmail));

Ten schemat naturalnie wpisuje się w middleware HTTP, event handlery, pipeline’y – miejsca, w których w wersji „książkowej” trzeba by budować klasy z referencją do dekorowanego komponentu. Kod funkcyjny jest tu krótszy, a wsparcie edytora przy generykach robi za dokumentację.

Composite i hierarchie danych zagnieżdżonych

Wzorzec Composite pasuje idealnie do TypeScripta, bo hierarchie drzewiaste da się bardzo naturalnie odwzorować jako typy zagnieżdżone. Klasy pojawiają się tylko wtedy, gdy naprawdę są potrzebne (np. do kapsułkowania zachowań).

type MenuItem =
  | { kind: 'link'; label: string; href: string }
  | { kind: 'group'; label: string; children: MenuItem[] };

const renderMenu = (item: MenuItem): string => {
  switch (item.kind) {
    case 'link':
      return `<a href="${item.href}">${item.label}</a>`;
    case 'group':
      return `<div>
        <span>${item.label}</span>
        ${item.children.map(renderMenu).join('')}
      </div>`;
  }
};

To jest Composite w formie danych. Jeśli logika zachowania elementów menu stanie się bardziej złożona (prawa dostępu, dynamiczne ładowanie), można wprowadzić klasy implementujące wspólny interfejs, ale dopóki struktura jest prosta, drzewo typów jest czytelniejsze niż hierarchia klas.

Proxy: asynchroniczność i leniwe wywołania

TypeScript żyje w świecie asynchronicznym, więc Proxy bardzo często służy do:

  • leniwego ładowania danych (np. detale użytkownika dopiero przy rozwinięciu w UI),
  • dodawania cache’u na poziomie wywołań metod,
  • kontroli uprawnień przed wykonaniem operacji.
interface UserService {
  getUser(id: string): Promise<User>;
}

const withCache = (service: UserService): UserService => {
  const cache = new Map<string, User>();

  return {
    async getUser(id) {
      if (cache.has(id)) return cache.get(id)!;
      const user = await service.getUser(id);
      cache.set(id, user);
      return user;
    }
  };
};

To minimalistyczny Proxy: obudowuje istniejący serwis i dokłada cache. Dzięki temu, że TS używa structural typing, nie trzeba tworzyć osobnego interfejsu CachedUserService – wystarczy, że obiekt ma tę samą sygnaturę metod.

Wzorce behawioralne a logika biznesowa w TypeScript

Strategy w świecie funkcji i klas

Strategy w TypeScripcie najczęściej kończy jako albo:

  • zestaw funkcji spełniających wspólny interfejs,
  • albo kilka klas implementujących ten sam kontrakt – gdy trzeba podpiąć stan, konfigurację i zależności.
type PriceStrategy = (basePrice: number) => number;

const noDiscount: PriceStrategy = (price) => price;

const percentageDiscount = (percent: number): PriceStrategy => {
  return (price) => price * (1 - percent / 100);
};

const fixedDiscount = (amount: number): PriceStrategy => {
  return (price) => Math.max(0, price - amount);
};

const calculateCartTotal = (items: number[], strategy: PriceStrategy) => {
  return items.reduce((sum, price) => sum + strategy(price), 0);
};

const totalWeekend = calculateCartTotal([100, 200], percentageDiscount(10));
const totalVip = calculateCartTotal([100, 200], fixedDiscount(50));

Bez klas, bez dziedziczenia, ale semantycznie to Strategy: algorytm naliczania ceny jest wymienny, a reszta systemu korzysta z niego przez prosty typ funkcji. TypeScript pilnuje sygnatury, IDE podpowiada strategie i nie trzeba pamiętać, co ma ile parametrów.

Kiedy jednak strategia potrzebuje konfiguracji, logowania, cache’u i paru innych „przyjaciół”, warto sięgnąć po klasy lub obiekty z metodami:

interface ShippingStrategy {
  getName(): string;
  calculate(order: Order): number;
}

class InPostShipping implements ShippingStrategy {
  constructor(private base: number, private perKg: number) {}

  getName() {
    return 'inpost';
  }

  calculate(order: Order) {
    return this.base + order.weightKg * this.perKg;
  }
}

class CourierShipping implements ShippingStrategy {
  constructor(private flat: number) {}

  getName() {
    return 'courier';
  }

  calculate(order: Order) {
    return this.flat;
  }
}

class ShippingCalculator {
  constructor(private strategy: ShippingStrategy) {}

  setStrategy(strategy: ShippingStrategy) {
    this.strategy = strategy;
  }

  calculate(order: Order) {
    return this.strategy.calculate(order);
  }
}

Takie podejście bywa przydatne w logice biznesowej, gdzie obiekty strategii krążą między modułami, są konfigurowane z zewnątrz (np. z panelu admina) albo wstrzykiwane przez DI. W prostym kodzie frontendowym lepiej jednak zacząć od funkcji i dopiero przy rosnącej złożoności przejść do wersji „klasowej”.

Command: gdy akcje muszą być zapisywalne, cofane lub kolejkowane

Command w TypeScripcie ujawnia swoją prawdziwą użyteczność, gdy:

  • trzeba móc cofać operacje (undo/redo w edytorze, panel admina),
  • akcje muszą być serializowane i odtwarzane (np. worker, kolejka wiadomości),
  • logika UI oderwana jest od tego, co naprawdę dzieje się w domenie.
interface Command {
  type: string;
  payload: unknown;
}

interface CommandHandler<T extends Command> {
  canHandle(command: Command): command is T;
  execute(command: T): Promise<void>;
}

class CreateUserCommandHandler implements CommandHandler<CreateUserCommand> {
  constructor(private userRepo: UserRepository) {}

  canHandle(command: Command): command is CreateUserCommand {
    return command.type === 'user.create';
  }

  async execute(command: CreateUserCommand) {
    await this.userRepo.create(command.payload);
  }
}

class CommandBus {
  constructor(private handlers: CommandHandler<any>[]) {}

  async dispatch(command: Command) {
    const handler = this.handlers.find(h => h.canHandle(command));
    if (!handler) {
      throw new Error(`No handler for command ${command.type}`);
    }
    await handler.execute(command as any);
  }
}

Command w tej formie jest bardzo naturalny w aplikacjach z CQRS albo tam, gdzie interfejs użytkownika produkuje „intencje”, a backend lub domena je realizuje. Zamiast „handlerów kliknięć” z całym biznesem w środku pojawiają się jasno nazwane polecenia: user.create, invoice.send, cart.clear.

W prostszych scenariuszach, gdzie akcja jest wywoływana w jednym miejscu i nie trzeba jej cofać ani serializować, Command bywa zwyczajnym nadmiarem. Funkcja createUser() wywołana bezpośrednio z onSubmit jest w porządku, o ile nie zaczyna „puchnąć” i nie jest kopiowana w pięciu miejscach.

Observer: eventy, RxJS i własne mini-busy

Środowisko JS/TS żyje eventami. Mając addEventListener, WebSockety, RxJS, bibliotekę event emitterów – wzorzec Observer często jest właściwie „wbudowany”. Mimo to w logice domenowej przydaje się własny, mały mechanizm, który nie miesza poziomu UI z biznesem.

type Listener<T> = (event: T) => void;

class EventBus<TEvents extends Record<string, any>> {
  private listeners: {
    [K in keyof TEvents]?: Listener<TEvents[K]>[]
  } = {};

  on<K extends keyof TEvents>(type: K, listener: Listener<TEvents[K]>) {
    this.listeners[type] ??= [];
    this.listeners[type]!.push(listener);
  }

  off<K extends keyof TEvents>(type: K, listener: Listener<TEvents[K]>) {
    this.listeners[type] =
      this.listeners[type]?.filter(l => l !== listener);
  }

  emit<K extends keyof TEvents>(type: K, event: TEvents[K]) {
    this.listeners[type]?.forEach(l => l(event));
  }
}

interface DomainEvents {
  'user.created': { id: string; email: string };
  'invoice.paid': { id: string; total: number };
}

const bus = new EventBus<DomainEvents>();

bus.on('user.created', (e) => {
  console.log('Send welcome email to', e.email);
});

bus.emit('user.created', { id: '1', email: 'a@example.com' });

Generyki pozwalają zachować silne typowanie pomiędzy nazwą eventu a jego danymi. To nie jest „wielki framework event sourcingu”, tylko kilkadziesiąt linijek, które odsprzęgają moduły: jeden publikuje zdarzenie, kilka innych reaguje. W aplikacjach z bogatą logiką biznesową taki prosty Observer często jest bardziej przejrzysty niż rozproszony system callbacków i importów w kółko.

Z drugiej strony, jeśli już w projekcie i tak używany jest RxJS, nie ma sensu wynajdywać koła. Streamy Subject<T> czy Observable<T> są po prostu „obserwerami na sterydach” – warto wtedy trzymać się jednego narzędzia, zamiast mnożyć implementacje.

State: ki