Sześć technik TypeScript, które przekształcają typy w skuteczną ochronę przed błędami
Dowiedz się, jak zastosowanie unii oznaczonych, braku sprawdzania, typów pochodnych oraz unikalnych identyfikatorów pomaga TypeScriptowi wykrywać prawdziwe błędy w czasie kompilacji, a nie w środowisku produkcyjnym.
Źródła: TypeScript Can Already Do This. Almost Nobody Uses It.
Projekt może wymieniać TypeScript w pliku package.json, deklarować interfejsy z kilkunastoma opcjonalnymi polami, a mimo to zawieszać się z błędem „cannot read properties of undefined”. Posiadanie zainstalowanego kompilatora to nie to samo, co jego skuteczne wykorzystanie, a dodawanie : string do każdej zmiennej samo w sobie niewiele daje. Poniższe sześć technik to rozwiązania stosowane przez doświadczone zespoły, aby sprawić, że sprawdzacz typów miał rzeczywisty wpływ – każda z nich zawiera fragment kodu oraz uzasadnienie.
Waliduj obiekty za pomocą funkcji satisfies zamiast adnotacji
Zwykłym sposobem na upewnienie się, że obiekt konfiguracyjny ma odpowiednią strukturę, jest jego adnotowanie:
const config: Record<string, number> = {
timeout: 3000,
retries: 3,
};
config.timeout.toFixed(2); // fine
To kompiluje się, ale adnotacja zastępuje to, co TypeScript miałby wywnioskować. Typ zmiennej jest teraz dokładnie Record<string, number>, więc kompilator już nie wie, jakie klucze faktycznie posiada obiekt. config.timeout to po prostu „jakaś liczba pod jakimś kluczem stringowym”, błąd pisowni taki jak config.timout jest akceptowany bez żadnych zastrzeżeń, a przy włączonym noUncheckedIndexedAccess każda odczytywana właściwość staje się number | undefined.
Operator satisfies, dostępny od wersji TypeScript 4.9, sprawdza obiekt pod kątem typu bez zastępowania wywnioskowanego typu:
const config = {
timeout: 3000,
retries: 3,
} satisfies Record<string, number>;
config.timeout; // TypeScript still knows this is literally 3000
Otrzymujesz walidację (wartość typu ciąg zostałaby odrzucona) i zachowujesz dokładną strukturę: TypeScript wie, że obiekt zawiera dokładnie pola timeout i retries, a błędnie napisane klucze powodują błędy. Jedna korekta powszechnego twierdzenia, włączając komentarz z przykładu: sama funkcja satisfies nie zapewnia, że timeout będzie miać typ literałowy 3000. Właściwości obiektu zmiennego są nadal rozszerzane do typu number. Jeśli potrzebujesz również wartości literałowych, połącz oba operatory jako as const satisfies Record<string, number>, co czyni obiekt niezmiennym i zachowuje wszystkie wartości literałowe. Wielu programistów nadal korzysta z tych adnotacji po prostu dlatego, że przez lata była to jedyna dostępna opcja.
Warianty modelu jako związki oznaczone tagami, a nie pola opcjonalne
Gdy wartość może przybierać kilka form, kuszącym sposobem obejścia jest ustawienie każdego pola jako opcjonalnego:
interface ApiResponse {
data?: User[];
error?: string;
loading?: boolean;
}
Typ teraz dopuszcza każdą kombinację, w tym data i error razem lub żadnego z nich przy loading ustawionym na false. Zasada, zgodnie z którą jednocześnie może obowiązywać tylko jeden stan, znajduje się w umysłach ludzi, a kompilator nie może jej egzekwować.
Unia dyskryminowana (unia z tagami) koduje tę zasadę. Każda wariantowa forma zawiera literalny tag status oraz tylko te pola, które do niej należą:
type ApiResponse =
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function render(response: ApiResponse) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={response.data} />; // data exists, guaranteed
case 'error':
return <ErrorBanner message={response.error} />;
}
}
Wewnątrz każdego case TypeScript zawęża wartość response do odpowiadającej mu wersji. Do response.data można uzyskać dostęp tylko w gałęzi success, ponieważ pozostałe wersje w ogóle nie posiadają właściwości data. Cała kategoria błędów typu „czy to jest tutaj zdefiniowane?” znika jeszcze przed uruchomieniem kodu, a ten sam schemat sprawdza się w przypadku działań reduktora, stanów formularzy oraz wyników API.
Zmuszenie brakujących przypadków do powodowania błędu podczas budowy przy użyciu never
Unie z tagami mają jeszcze jedną wadę. Załóżmy, że po kilku miesiącach ktoś dodaje status 'forbidden'. Nic nie zmusza do aktualizacji każdego switch opartego na typie ApiResponse, więc nowy przypadek przechodzi bez echa i pojawia się jako błąd w produkcji.
Sprawdzenie kompletności eliminuje tę lukę. Gałąź default przypisuje zwężoną wartość zmiennej o typie never:
function render(response: ApiResponse) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={response.data} />;
case 'error':
return <ErrorBanner message={response.error} />;
default:
const exhaustiveCheck: never = response;
throw new Error(`Unhandled status: ${exhaustiveCheck}`);
}
}
Gdy obsłużono już wszystkie warianty, nie pozostaje nic dla response w gałęzi default, więc jej typ to never i przypisanie kompiluje się pomyślnie. Jeśli dodamy 'forbidden' bez odpowiadającego mu case, to response stanie się { status: 'forbidden' }, co nie może być przypisane do typu never, w związku z czym kompilacja zawodzi i wskazuje bezpośrednio na zapomnianą gałąź. Wywołanie throw chroni również w czasie wykonywania, jeśli mimo wszystko pojawią się nieoczekiwane dane. Umieszczenie ciała gałęzi default w nawiasach sprawia, że narzędzia do analizy kodu nie zgłaszają problemów z deklaracjami znajdującymi się w klauzulach case.
Używaj typu unknown na krańcach swojej aplikacji
any wyłącza sprawdzanie typów. unknown utrzymuje je włączone i zmusza do udowodnienia, czym jest dana wartość, przed jej użyciem. To dokładnie takie zachowanie jest potrzebne tam, gdzie pojawiają się dane niepewne: odpowiedzi API, wyniki JSON.parse, treści webhooków.
Tutaj pomocnik fetch celowo zwraca Promise<unknown>. Bezpośrednie odczytanie .name powoduje błąd kompilacji, a jest dozwolone jedynie wewnątrz mechanizmu typu guard:
async function fetchUser(id: string): Promise<unknown> {
const res = await fetch(`/api/user/${id}`);
return res.json();
}
const raw = await fetchUser('123');
raw.name; // compile error, unknown has no properties
if (isUser(raw)) {
raw.name; // fine, narrowed to User inside the guard
}
isUser to użytkowniczo zdefiniowany typ guard o sygnaturze function isUser(x: unknown): x is User, a jego treść musi rzeczywiście sprawdzać strukturę danych w czasie wykonywania. Przy użyciu typu unknown kompilator odmawia dostępu do właściwości, dopóki dane nie zostaną zweryfikowane. W przypadku typu any walidacja jest opcjonalna, a takie sprawdzenia często są pomijane, dopóki nieotrzymana, błędna odpowiedź trafia do komponentu, który zakładał istnienie określonego pola. W przypadku większych obciążeń danych biblioteki do tworzenia schematów, takie jak Zod, mogą generować zarówno sprawdzenie w czasie wykonywania, jak i odpowiedni typ. Więcej wzorów na to omówiono w artykule sześć bezpiecznych zamienników typu any w TypeScript.
Pochodzenie typów z kodu zamiast utrzymywania kopii
Jeśli funkcja już tworzy wartość, nie potrzebujesz oddzielnej, ręcznie napisanej interfejsu dla jej rezultatu. TypeScript może to wyodrębnić:
function createOrderPayload(order: Order) {
return {
id: order.id,
total: order.total,
lineItems: order.items.map(i => ({ sku: i.sku, qty: i.qty })),
};
}
type OrderPayload = ReturnType<typeof createOrderPayload>;
ReturnType<typeof createOrderPayload> odczytuje typ zwracany przez funkcję, który został wywnioskowany automatycznie. Po zmianie funkcji OrderPayload aktualizuje się we wszystkich miejscach, gdzie jest używany. Interfejs utrzymywany ręcznie natomiast ulega dewiacjom: po kilku modyfikacjach może twierdzić, że istnieje pole, którego funkcja już dawno nie zwraca, a kompilator wciąż ufa temu przestarzałemu interfejsowi.
Ta sama zasada dotyczy komponentów z bibliotek, których nie posiadasz. React.ComponentProps<typeof Button> zwraca dokładnie te właściwości, które przyjmuje Button od third party, pobrane z jego własnych definicji typów, więc nie ma żadnej „cieniowej” kopii do aktualizacji, gdy biblioteka dodaje nową właściwość. Powiązane narzędzia takie jak Parameters, Awaited oraz typy dostępu przez indeks (OrderPayload['lineItems'][number]) rozszerzają ten podejście; szczegółowy przegląd znajdziesz w artykule wbudowanych typów pomocniczych TypeScript.
Podstawowe typy marki zapobiegające zamianie identyfikatorów
Domyślnie zarówno UserId, jak i OrderId to zwykłe łańcuchy znaków, więc przekazanie identyfikatora zamówienia do funkcji oczekującej ID użytkownika kompiluje się bez problemów. Zapytanie po prostu nic nie zwraca, a ktoś musi tracić czas na ustalenie przyczyny.
Branding łączy to, co prymitywne, z właściwością fantomową, przez co obie te rzeczy stają się niekompatybilne:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function getUser(id: UserId) { /* ... */ }
const orderId = 'ord_123' as OrderId;
getUser(orderId); // compile error: OrderId is not assignable to UserId
Właściwość __brand nigdy nie istnieje w czasie wykonywania; istnieje jedynie w systemie typów, co wystarcza, aby TypeScript traktował te dwa typy jako odrębne. Wartości są tworzone za pomocą małego konstruktora lub funkcji walidacyjnej (w przykładzie zastępuje to przekształcenie as OrderId), a od tego momentu każde przypadkowe pomieszanie powoduje błąd kompilacji bez żadnych kosztów w czasie wykonywania. Zachowaj przekształcenia w tych nielicznych konstruktorach, aby pojęcie brand rzeczywiście oznaczało „ta wartość została sprawdzona”.
Podsumowanie
Nie jest potrzebne żadne nowe framework ani biblioteka; chodzi o istniejący już TypeScript, używany w sposób celowy:
satisfieswaliduje bez odrzucania wywnioskowanej struktury; dodajas const, gdy liczą się literalne wartości.
never zamienia zapomniany przypadek w błąd kompilacji.unknown na granicach zmusza do weryfikacji przed użyciem.Wybierz jedną z tych technik i zastosuj ją w swoim następnym pull request. Każda z nich przenosi określoną klasę błędów z czasu wykonywania na czas kompilacji, co stanowi praktyczną różnicę pomiędzy posiadaniem zainstalowanego TypeScript a jego ochroną kodu.
Literatura pokrewna
- Odpowiedzi na rozmowy rekrutacyjne o React i JavaScript, które pokazują prawdziwą głębię — Odkryj silniejsze, bardziej wyrafinowane odpowiedzi na częste pytania z rozmów rekrutacyjnych dotyczące React i JavaScript, od Virtual DOM po projektowanie systemów, które demonstrują głębszą biegłość inżynierską.
- Dlaczego abstrakcje frontendu cicho przekształcają się w dług techniczny — Dowiedz się, dlaczego przedwczesne stosowanie abstrakcji w frontendzie dodaje ukrytą złożoność oraz jak ocenić, czy wspólne komponenty, hooki lub narzędzia rzeczywiście warto tworzyć.