Wybieranie wzorców TypeScript według stopnia złożoności, który eliminują
Przewodnik po klasycznych wzorcach projektowych i technikach TypeScript na poziomie typów, z jasnymi wskazówkami dotyczącymi tego, kiedy każdy z nich ma sens, a kiedy lepszy jest zwykły kod.
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.
Rozważaj system typów jako coś, co 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
ProductIdmoże trafić do funkcji, która oczekujeUserId? - Czy komponent może otrzymać właściwości, które są ze sobą sprzeczne?
- Czy po dodaniu nowego stanu każdy użytkownik będzie zmuszony się nim zajmować?
any?Wzorzec to nie funkcja, którą dodaje się później – to nazwa rozwiązania, które poznajemy, gdy pojawi się powtarzający się problem.
Zmapowanie środowiska
Klasyczny podział obejmuje wzorce kreacyjne, strukturalne i behawioralne; TypeScript dodaje czwartą grupę 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 z nich, a każda granica może mieć określone typy:
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 zachowywać się zgodnie z określonymi regułami.
Zacznij od problemu, a nie od wzorca
Częstym błędem jest rozumowanie w niewłaściwym kierunku:
"I know Factory Pattern.
Where can I use Factory?"
Wybór wzorca najpierw prowadzi do powstania 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 proces płatności, w którym dodano serię sprawdzeń dotyczących metody płatności:
if (paymentMethod === "card") {
// ...
}
if (paymentMethod === "paypal") {
// ...
}
if (paymentMethod === "upi") {
// ...
}
Często pierwszą reakcją jest właśnie to:
Don’t immediately think:
„To wymaga wzorca Strategy”. Zanim go użyjesz, zastanów się, czy te różne rozwiązania rzeczywiście są wymiennymi algorytmami realizowanymi w ramach jednego kontraktu. Jeśli tak, wzorzec Strategy się nadaje. Jeśli prawdziwym problemem jest to, że niektóre pola mają sens tylko w kontekście określonych metod, lepszym rozwiązaniem może być zjednoczenie dyskryminowane, które uniemożliwia reprezentację nieprawidłowych kombinacji. Znajomość takich wzorców jest prosta; umiejętność dopasowania ich do konkretnych sytuacji to już prawdziwa umiejętność.
Wzorce kreacyjne
Singleton: jedna wspólna instancja
Singleton zapewnia, że klasa ma dokładnie jedną instancję. Prywatny konstruktor blokuje dostęp spoza wyrażenia new, a metoda statyczna w sposób opóźniony tworzy i przechowuje tę instancję w pamięci:
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);
}
}
Wywołujące funkcje proszą o obiekt współdzielony zamiast tworzyć nowy:
const logger = Logger.getInstance();
logger.log("Application started");
Każdy użytkownik ostatecznie odwołuje się do tego samego obiektu:
Logger
│
getInstance()
│
▼
┌───────────┐
│ Logger │
│ Instance │
└───────────┘
▲ ▲
│ │
Service A Service B
Do rozsądnych kandydatów należą:
- systemy logowania
- menedżery analityki
- nośniki konfiguracji
- niektóre menedżery połączeń
- inne usługi infrastrukturalne o charakterze przekrojowym
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 definicji, cykl życia staje się niejasny, a wspólny, zmienny stan zmienia się w nierozś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ń.
Fabryka: ukrycie klasy, która jest tworzona
Fabryka przenosi decyzję o tym, która konkretna klasa ma zostać zainstancjowana, z użytkownika. Porównaj bezpośrednią konstrukcję:
const payment = new StripePayment();
z konstrukcją delegowaną:
const payment = PaymentFactory.create("stripe");
Oba dostawcy implementują jedną interfejs, a fabryka mapuje unię literów tekstowych na odpowiednią klasę. Ponieważ typ parametru to "stripe" | "paypal", nieobsłużona nazwa powoduje 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 to wideliec oznaczony nazwą dostawcy:
PaymentFactory
│
┌────────────┴────────────┐
│ │
"stripe" "paypal"
│ │
▼ ▼
StripePayment PayPalPayment
Fabryka okazuje się przydatna, gdy:
- budowa obejmuje rzeczywistą logikę
- wiele implementacji dzieli jeden kontrakt
- klienci nie powinni znać konkretnych klas
- implementacje muszą ewoluować niezależnie od wywołujących je elementów
W przypadku prostej budowy, jak ta poniżej, dodaje to jedynie warstwę do przeczytania:
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 przy jego użyciu przesadzić z budową. W większości aplikacji frontendowych zwykła kompozycja komponentów pozwala osiągnąć ten sam efekt 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();
Syntaksa płynna jest efektem ubocznym, a nie celem. Chodzi o to, by złożoną budowę utrzymać w formie wyraźnej i w jednym miejscu, gdzie build() może również sprawdzać dane – na przykład odrzucając ciało żądania w przypadku żądania GET.
Wzory strukturalne
Adapter: stabilna umowa wokół API innej biblioteki
Adapter jest jednym z najczęściej używanych wzorów w kodzie aplikacji. Załóżmy, że twój kod oczekuje takiego wewnętrznego kontraktu:
interface PaymentGateway {
pay(amount: number): Promise<void>;
}
podczas gdy SDK od third party oferuje metodę o innym nazewnictwie:
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 zależy teraz od jednej interfejsu, który należy do ciebie:
PaymentGateway
a nie od każdego dostawcy, który go używa:
Stripe
PayPal
LegacySDK
SomeFutureProvider
Wsparcie nowego dostawcy oznacza konieczność napisania kolejnego adaptera.
Fasada: jedno wywołanie dla wieloetapowego procesu
Fasada umieszcza prosty punkt wejścia przed złożonym podsystemem. Proces logowania może dotyczyć kilku usług:
Authentication
+
User Service
+
Permissions
+
Notification
+
Analytics
Bez fasady każdy komponent odpowiedzialny za logowanie użytkownika sam koordynuje tę sekwencję:
auth.login();
user.load();
permission.load();
analytics.track();
Fasada przekształca tę koordynację tylko 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 zachowanie bez zmiany oryginalnej implementacji, ponieważ obiekt otaczający i 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}`
);
}
}
Otaczanie to po prostu proces tworzenia obiektu:
const logger = new TimestampLogger(
new ConsoleLogger()
);
Ponieważ każdy dekorator sam w sobie jest Logger, są one nakładane jeden na drugi:
Logger
│
▼
ConsoleLogger
│
▼
TimestampLogger
│
▼
AdditionalDecorator
Ta sama idea występuje pod innymi nazwami:
- łańcuchy middleware
- funkcje otaczające
- komponenty wyższego rzędu w React
- logowanie
- warstwy cache’owania
- weryfikacje uprawnień
Wzorce zachowań
Strategia: algorytmy wymienne
Strategia eliminuje złożone reguły biznesowe. Weźmy przykład typu poziomu klienta:
type CustomerType =
| "regular"
| "premium"
| "enterprise";
Implementacja warunkowa umieszcza każdą regułę 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 strategii każda reguła staje się klasą realizującą wspólny interfejs:
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 nowego poziomu oznacza teraz zwykle dodanie strategii zamiast edycji istniejącej logiki, co jest praktycznym zastosowaniem Zasady Otwartości i Zamknięcia. Dla trzech prostych reguł implementacja warunkowa jest wystarczająca; strategia okazuje się przydatna, gdy reguły zaczynają mieć własne zależności lub testy.
Obserwator: powiadomienia jeden-do-wielu
Obserwator umożliwia jednemu obiektowi podmiotowemu wysyłanie powiadomień do dowolnej liczby obserwatorów w momencie zmiany stanu:
Subject
│
┌──────────┼──────────┐
▼ ▼ ▼
Observer A Observer B Observer C
Niewielki typowany emiter przechowuje słuchacze w Set i zwraca funkcję do czyszczenia po zarejestrowaniu słuchacza:
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 do czyszczenia służy do zarządzania cyklem życia komponentu. Zapomnienie o jej wywołaniu prowadzi do:
- wycieków pamięci spowodowanych słuchaczami, które przetrwają swoich właścicieli
- duplikacji obsługi, gdy słuchacz zostanie dołączony dwa razy
- używania przestarzałych wartości przez zamknięcia funkcji
- skutków ubocznych występujących po zniknięciu komponentu
W React dokładnie to jest zwracane z funkcji callbackowej useEffect.
Polecenie: działania jako obiekty
Polecenie przekształca działanie w obiekt o jednolitej interfejsie:
interface Command {
execute(): void;
}
Konkretne polecenia je implementują:
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
- funkcji cofnięcia i ponownego wykonania
Prawdziwe cofanie operacji 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
Wzory danych i kompozycji
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 polega na interfejsie:
UserRepository
a nie na mechanizmie transporту:
fetch()
axios()
graphqlClient()
To ma znaczenie, gdy zmienia się źródło kodu: przejście z REST na GraphQL, dodanie pamięci cache IndexedDB lub użycie wersji tymczasowej w pamięci 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
rozdziel odpowiedzialności na bardziej specyficzne części:
Dashboard
├── Header
├── Sidebar
├── FilterPanel
├── DataTable
└── NotificationPanel
W React to polega po prostu na układaniu komponentów warstwowo:
<Dashboard>
<Header />
<Sidebar />
<MainContent />
</Dashboard>
Każda z tych części może być zrozumiana, przetestowana i zastąpiona osobno. Aby dowiedzieć się więcej na temat rozbierania zbyt rozbudowanych komponentów, zapoznaj się z rozwiązaniami problemu przeładowania właściwościami w React za pomocą kompozycji i slotów.
Modelowanie stanu za pomocą systemu typów
Od tego momentu sam TypeScript jest narzędziem architektonicznym.
Zjednoczenia dyskryminowane dla stanu żądania
Żądanie przechodzi przez różne fazy, w których przetwarzane są różne dane. Zjednoczenie dyskryminowane 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;
};
Użycie dyskryminantu pozwala zawęzić 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 z powszechnym modelem boolowym i opcjonalnym:
interface State {
loading: boolean;
data?: User[];
error?: string;
}
Nic nie stoi na przeszkodzie opisaniu stanu, który nigdy nie powinien wystąpić:
{
loading: true,
data: [...],
error: "Something failed"
}
Zjednoczenie utrudnia wyrażenie takich kombinacji. Modeluj ważne stany zamiast rozrzucać opcjonalne właściwości i polegać na tym, że wszyscy połączą je prawidłowo.
Typy wynikowe dla wyraźnego niepowodzenia
Wiele operacji ma dokładnie dwa wyniki:
Success
OR
Failure
Typ Result opisuje je oba za pomocą dyskryminantu boolowskiego:
type Result<T, E> =
| {
success: true;
data: T;
}
| {
success: false;
error: E;
};
Funkcja, która go zwraca:
function getUser(): Result<User, string> {
return {
success: true,
data: user
};
}
Wywołujący musi sprawdzić wartość success, zanim dostąpi 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 odpowiada oczekiwanym błędom biznesowym, takim jak „adres e-mail jest już zarejestrowany”. Wyjątki nadal pasują do rzeczywistych błędów i awarii infrastruktury; typ Result po prostu sprawia, że przewidziane błędy są widoczne w sygnaturze.
Generyki i narzędzia na poziomie typów
Generyki zachowują związek między wejściem a wyjściem
Użycie any eliminuje informacje:
function identity(value: any): any {
return value;
}
Parametr typu je zachowuje:
function identity<T>(value: T): T {
return value;
}
Wynik wywnioskowany odpowiada argumentom:
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
Genery 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 treści danych:
type UserResponse =
ApiResponse<User>;
type ProductResponse =
ApiResponse<Product>;
Warunki: wymaganie określonej struktury
To nie kompiluje się:
function getId<T>(item: T) {
return item.id;
}
ponieważ nic nie informuje TypeScript, że T ma pole id. Warunek dodaje tę gwarancję:
function getId<T extends { id: string }>(
item: T
) {
return item.id;
}
Przyjmowany jest każdy obiekt z polem typu string o nazwie id, włączając dodatkowe pola:
getId({
id: "123",
name: "Hareesh"
});
Warunek należy interpretować w ten sposób:
T can be anything
BUT
T must have id: string
keyof i dostęp indeksowany
Danej interfejsowi:
interface User {
id: string;
name: string;
age: number;
}
keyof generuje zbiór nazw jego właściwości:
type UserKey = keyof User;
"id" | "name" | "age"
Łączenie keyof z drugim parametrem typu oraz typem dostępu indeksowanego T[K] daje dostępnik, którego typ zwracanych wartości odpowiada kluczu:
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");
Rzeczywisty klucz kompiluje się poprawnie:
getProperty(user, "name");
Brakujący klucz powoduje błąd kompilacji:
getProperty(user, "salary");
Moc wynika z współpracy trzech narzędzi:
Generics
+
keyof
+
Indexed Access
Typy mapowane: transformacja istniejącego typu
Typy mapowane przetwarzają klucze danego typu w celu utworzenia nowego typu. Począwszy od:
interface User {
id: string;
name: string;
email: string;
}
można utworzyć wersję z wyłącznie opcjonalnymi elementami:
type OptionalUser = {
[K in keyof User]?: User[K];
};
koncepcyjnie równoznaczne z napisaniem:
{
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
Ten mechanizm rozwija typy elementów tablicy i pozostawia wszystko inne bez zmian:
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 w czasie kompilacji, co wymaga ostrożności.
infer: wyodrębnianie 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 wywnioskować 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 w bibliotekach w dużej mierze od tego zależą.
Najpierw wbudowane typy pomocnicze
Zanim napiszesz sprytnych helperó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 odbywa się w jednej linii, zamiast poprzez duplikację interfejsu, która powoduje brak 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ę uprawnienie, TypeScript zgłasza brakujący klucz. Szeroki typ klucza traci tę gwarancję:
const permissions: Record<string, boolean>
ponieważ Record<string, boolean> akceptuje praktycznie każdy klucz typu string, więc pominięcia i błędy pisowni pozostają niezauważone.
Wzorce bezpieczeństwa dla identyfikatorów i niepewnych danych
Typy specjalne dla identyfikatorów domeny
Oba identyfikatory mogą być łańcuchami znaków, ale oznaczać różne rzeczy:
const userId: string;
const productId: string;
Strukturalnie TypeScript nie potrafi ich odróżnić. Skrzyżowanie typu string z wymyśloną właściwością tworzy odrębne typy:
type UserId =
string & {
readonly __brand: "UserId";
};
type ProductId =
string & {
readonly __brand: "ProductId";
};
Funkcje mogą wtedy wymagać odpowiedniego rodzaju identyfikatora:
function getUser(id: UserId) {}
function getProduct(id: ProductId) {}
Przekazanie ProductId tam, 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 walidacyjnej, która dokonuje przekształcenia w jednym miejscu. Koncepcyjnie:
string
│
├── UserId
├── ProductId
├── OrderId
└── TransactionId
To przynosi korzyści w dużych systemach, gdzie dziesiątki identyfikatorów dzieli ten sam typ prymitywny.
Zaawansowane mechanizmy obronne dla danych typu unknown
Dane z sieci, pamięci lub wprowadzone przez użytkownika powinny trafiać jako unknown:
const data: unknown = await response.json();
Przekształcenie typu jest kuszącą skrótnością:
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 dowodzi wynik true:
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ą usuwane w czasie wykonywania. Interfejs taki jak ten:
interface User {
id: string;
}
nie weryfikuje nic z tego, co wysyła API. Powyższy strażnik sprawdza jedynie, czy klucze istnieją, a nie ich typy, więc dla niepewnych danych wejściowych użyj TypeScript w połączeniu z walidatorem schematu w czasie wykonywania.
Kompleksowa weryfikacja za pomocą never
Jedna z najskuteczniejszych kombinacji w tym języku:
Discriminated Union
+
never
+
switch
Weź zbiór stanów:
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 zostaną obsłużone, gałąź domyślna widzi 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 union:
Now imagine someone adds:"cancelled" to Status.
Łańcuch domyślny otrzymuje "cancelled", który nie może zostać przypisany do never, więc budowa zawodzi, dopóki ten przypadek nie zostanie obsłużony. Kompilator staje się recenzentem projektu, który pamięta każdego użytkownika.
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 union 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 zdarzeń
- klucze zdarzeń analitycznych
- ciągi znaków uprawnień
- wzory tras
- nazwy flag funkcjonalnych
- tokeny systemu projektowego
as const gdy wartości są źródłem prawdy
Literał tablicy 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 unii:
type Role =
typeof roles[number];
becomes:
"admin" |
"editor" |
"viewer"
Zdefiniuj wartości raz i nigdy nie utrzymuj ręcznie równoległej unii.
satisfies dla skonfigurowanej konfiguracji
satisfies weryfikuje wyrażenie pod kątem typu bez jego rozszerzania do tego typu:
type Config = {
retries: number;
environment:
| "development"
| "production";
};
const config = {
retries: 3,
environment: "production"
} satisfies Config;
Obiekt jest sprawdzany pod kątem Config, jednak config.environment zachowuje literalny typ "production", który zwykła adnotacja utraciłaby. Nadaje się do:
- konfiguracji tras
- flag funkcjonalnych
- tokenów projektowych
- obiektów ustawień statycznych
- map uprawnień
Injekcja zależności
Tworzenie zależności wewnątrz klasy łączy ją z nimi:
class UserService {
private api = new ApiClient();
}
Otrzymywanie ich przez konstruktora utrzymuje klasę w stanie niezależności:
class UserService {
constructor(
private readonly api: ApiClient
) {}
}
W środowisku produkcyjnym używa się prawdziwego klienta:
const service =
new UserService(apiClient);
a w testach – zamiennika:
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 wystarczą do tego parametry konstruktora.
Rozróżnianie podobnych wzorców
Wzorzec stanu czy zjednoczenie dyskryminowane?
Biorąc pod uwagę cykl życia zamówienia:
Draft
Paid
Shipped
Cancelled
zjednoczenie dyskryminowane może być wszystkim, czego potrzebujesz:
type Order =
| { status: "draft" }
| { status: "paid" }
| { status: "shipped" }
| { status: "cancelled" };
Ale gdy każdy stan zawiera znaczną ilość logiki:
Draft
├── edit()
├── submit()
Paid
├── refund()
├── ship()
Shipped
├── track()
└── deliver()
wzorzec stanu, 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 Stan?
Częsty temat rozmów. Dzięki strategii klient wybiera algorytm:
Order
│
▼
Strategy
├── CreditCard
├── PayPal
└── UPI
Dzięki stanie obiekt zmienia swoje zachowanie w miarę przechodzenia przez swój cykl życia:
Order
│
├── Draft
├── Paid
└── Shipped
Strategia wpływa na sposób wykonywania zadania; stan określa, co robi obiekt 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ć uruchomione?”:
new Order(discountStrategy);
Łączą się one naturalnie – fabryka wytwarza strategię, która wykonywać będzie pracę:
Factory
↓
creates
↓
Strategy
↓
executes behavior
Zastosowanie wzorców w React
Atrybuty 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 niebezpieczny:
interface ButtonProps {
primary?: boolean;
danger?: boolean;
loading?: boolean;
}
Unia 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 odrzucające 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ą, wariant z rozróżnieniem oraz poprawne użycie:
{
href?: string;
onClick?: () => void;
}
use:
type ActionProps =
| {
type: "link";
href: string;
}
| {
type: "button";
onClick: () => void;
};
Now:
<Action
type="link"
href="/users"
/>
Wariant linku z obsługą kliknięcia zamiast atrybutu href jest odrzucany:
<Action
type="link"
onClick={...}
/>
Obsługiwane tryby, sformułowane jasno:
Link
Button
TypeScript jest teraz częścią architektury komponentów, a nie dokumentacją, która ulega przestarzeniu.
Bus zdarzeń bezpieczny pod względem typów
Najpierw utworzmy mapę od nazw zdarzeń do typów danych:
type Events = {
"user:created": User;
"user:deleted": UserId;
"order:created": Order;
};
Generyk busa oparty na tej mapie łączy każdą nazwę z jej danymi 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 kompiluje się:
bus.emit("user:created", order);
Tutaj spotykają się różne narzędzia:
Generics
+
keyof
+
Indexed Access
+
Mapped Type thinking
Typy w całej warstwie API
Zrozwinięty frontend często organizuje dostęp do danych w ten sposób:
Component
↓
Hook
↓
Service
↓
Repository
↓
API Client
↓
HTTP
przy czym typy są przenoszone na każdym kroku. Repozytorium może przyjmować specjalne identyfikatory i zwracać Result:
type ApiResponse<T> = {
data: T;
status: number;
};
interface UserRepository {
getUser(id: UserId):
Promise<Result<User, ApiError>>;
}
Komponent nie musi już zajmować się tym na każdej warstwie:
any
Samych sygnatur wystarcza, by wiedzieć, co może się udać, co może zawieść i z jakimi typami.
Antypatrony, których należy unikać
any wszędzie
function process(data: any) {}
Parametr any wyłącza sprawdzanie wszystkiego, z czym ma do czynienia. Lepiej używać:
function process(data: unknown) {}
i zawężać typy przed użyciem.
Stwierdzenia jako walidacja
const user =
response.data as User;
Przekształcenie typu za pomocą as nic nie sprawdza; polega jedynie na prośbie do kompilatora o zaufanie. Waliduj dane niewiarygodne w czasie wykonywania.
Genery 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.
Ogromne unie
Unia z dyskryminacją składającą się ze stu elementów jest trudna do nawigowania i rozwijania. W takich przypadkach może być potrzebna inna abstrakcja, np. zagnieżdżone unie lub przeniesienie różnic do samych danych.
Chytrość 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
Bезpieczeństwo typów ma swoją cenę. Dąż do maksymalnie użytecznego bezpieczeństwa na jednostkę złożoności, a nie do najbardziej zaawansowanych typów.
Drzewo decyzyjne do wyboru
To drzewo łączy typowe wyzwania z odpowiednimi wzorcami:
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.
Czego uczyć się najpierw
Dla inżynierów front-end przygotowujących się do stanowisk kierowniczych lub senioralnych zapamiętywanie wszystkich wzorców z grupy Gang of Four to niewydajne wykorzystanie czasu. Praktyczna kolejność:
Najważniejsze elementy:
Discriminated Unions
Generics
Type Guards
keyof
Mapped Types
Utility Types
Composition
Strategy
Repository
Factory
Ściśle zalecane dalsze kroki:
Result Type
Branded Types
Conditional Types
infer
Exhaustive Checking
Dependency Injection
Adapter
Facade
Observer
Decorator
Warto zrozumieć koncepcyjnie:
Builder
Command
State
Abstract Factory
Singleton
Na co dzień praca nad front-endem w dużej mierze opiera się na tej kombinacji:
Generics
+
Discriminated Unions
+
Composition
+
Strategy
+
Repository
niż na jakiejkolwiek teoretycznej Abstrakcyjnej Fabryce.
Jak zmienia się ocena z upływem doświadczenia
Na początku inżynierowie pytają: „Który wzorzec powinienem tu użyć?”. Z doświadczeniem w obsłudze dużych systemów pytanie to zmienia się na: „Jaka jest najprostsza abstrakcja, która rozwiąże 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 rozmowy kwalifikacyjnej sprawdzające rzeczywiste zrozumienie
„Co to jest wzorzec fabryczny?” sprawdza pamięć. Te pytania testują umiejętność osądu.
Architektura:
- W obliczu komponentu React składającego się z 2000 linii, jak decydujesz, którą abstrakcję wprowadzić?
- Kiedy odmówiłbyś użycia wzorca, który wydaje się odpowiedni?
- Jak można stwierdzić, że abstrakcja jest przedwczesna?
Zasady podstawowe TypeScript:
- Jak zamodelować żądanie z stanami ładowania, sukcesu, błędu i ponownej próby?
- Jak zapobiec nieprawidłowym kombinacjom propów w React?
- W czym różnią się
unknown,anyinever? - Kiedy wybrać
typezamiastinterface, i odwrotnie? - Jak
keyofwspółdziała z generykami?
Zaawansowany TypeScript:
- Jaki jest konkretny przypadek użycia typów warunkowych?
- Czym zajmuje się
infer? - Co to jest typ mapowany?
- Czym są typy warunkowe dystrybutywne?
- Kiedy warto używać typów branded?
- Jaki problem rozwiązuje
satisfies? - Jak
as constzmienia proces wnioskowania?
Projektowanie w praktyce:
- Zaprojektuj bezpieczny pod względem typów bus zdarzeń.
- Zaprojektuj bezpieczny pod względem typów klient API.
- Zaprojektuj system uprawnień przy użyciu TypeScript.
- Zaprojektuj abstrakcję płatności obsługującą Stripe, PayPal oraz innego dostawcę.
- Jak dodałbyś warstwę Repository do istniejącego aplikacji React bez jej przepisywania?
- Jak typizowałbyś zdarzenia WebSocket o różnych treściach?
- Jak zapobiegłbyś przekazywaniu
ProductIdtam, gdzie wymagany jestUserId?
Jedno z pytań ujawniających prawdę: „Opisz sytuację, gdy celowo zdecydowałeś się nie używać wzorca projektowego”. Dobra odpowiedź wyjaśnia kompromis: przy jednej implementacji i braku prawdopodobnych zmian, które wymagałyby ochrony, pośrednictwo nie zmniejszyłoby złożoności, więc kod pozostał prosty, dopóki drugi przypadek użycia nie uzasadnił tego zmiany.
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
Główne wnioski
Wzory działają najlepiej jako wspólna terminologia: „to jest adapter”, „te zachowania są wymienne”, „te stany należą do unii”, „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 wzory pod kątem złożoności, którą eliminują, a nie pod kątem znajomości.
- Niech unie, typy
Resultoraz wyczerpujące sprawdzenia będą preferowane przed polami opcjonalnymi i nadzieją. - Waliduj dane na granicach czasu wykonywania; same typy nie chronią cię w tych sytuacjach.
- Wybieraj najprostszą abstrakcję, która radzi sobie ze zmianami, których faktycznie oczekujesz.
Literatura pokrewna
- Kierowanie agentami programistycznymi AI za pomocą czterech nazwanych wzorców architektury React — Poznaj cztery wzorce React (składanie sekcji, niestandardowe hooki, komponenty polimorficzne, reduktory) oraz to, jak nadawanie im nazw w instrukcjach pozwala uzyskać czystszy kod wygenerowany przez AI.
- Wzorce projektowe React: od klasycznego OOP po nowoczesne hooki — Wyjaśnia, w jaki sposób klasyczne wzorce programistyczne takie jak Singleton, Factory i Observer znajdują zastosowanie w React, a także wzorce specyficzne dla React, takie jak HOC-y, hooki i komponenty złożone.