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ść.
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
ProductIdmoże trafić do funkcji, która oczekujeUserId? - 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ć?
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:
- W obliczu komponentu React składającego się z 2000 linii kodu, jak decydujesz, jaką abstrakcję wprowadzić?
- Kiedy odmówiłbyś użycia wzorca, który wydaje się odpowiedni?
- Jak można stwierdzić, że abstrakcja jest przedwczesna?
- Kiedy kompozycja jest lepsza od dziedziczenia?
- Jak zapobiec temu, by Singleton stał się globalnym, zmienialnym stanem?
Podstawy TypeScript:
- Jak zamodelować żądanie z stanami ładowania, sukcesu, błędu i ponownej próby?
- Jak zapobiec nieprawidłowym kombinacjom właściwości React?
- W czym różnią się
unknown,anyinever? - Kiedy wybrać
typezamiastinterfacei odwrotnie? - W jaki sposób
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?
- Co to są typy warunkowe dystrybutywne?
- Kiedy typy markowane są przydatne?
- Jaki problem rozwiązuje
satisfies? - W jaki sposób
as constzmienia proces wnioskowania?
Projektowanie w praktyce:
- Zaprojektuj bezpieczny pod względem typów bus zdarzeń.
- Zaprojektuj bezpiecznego pod względem typów klienta API.
- Zaprojektuj system uprawnień przy użyciu TypeScript.
- Zaprojektuj abstrakcję płatności obsługującą Stripe, PayPal oraz innego dostawcę.
- Jak dodać warstwę Repository do istniejącego aplikacji React bez jej przepisywania?
- Jak definiować wydarzenia WebSocket z różnymi treściami?
- Jak zapobiec przekazywaniu
ProductIdtam, gdzie wymagany jestUserId?
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
Resultoraz 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
- Kierowanie agentami kodującymi AI za pomocą czterech nazwanych wzorców architektury React: Poznaj cztery wzorce React (składanie sekcji, niestandardowe hooki, komponenty polimorficzne, reduktory) oraz to, jak ich nazewnictwo w instrukcjach daje ci czystszy kod generowany przez AI.