Strona główna / Artykuły

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.

Wydawca: REACTAPP.TOP7 min czytania1627 słów

Ź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:

  • satisfies waliduje bez odrzucania wywnioskowanej struktury; dodaj as const, gdy liczą się literalne wartości.
  • Związki oznaczone sprawiają, że niemożliwe stany stają się niereprezentowalne.
  • Kontrola typu never zamienia zapomniany przypadek w błąd kompilacji.
  • Wartość unknown na granicach zmusza do weryfikacji przed użyciem.
  • Typy pochodne pozostają zsynchronizowane z kodem, który wytwarza wartości.
  • Marki oddzielają semantycznie różne łańcuchy znaków.
  • 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

  • Złapanie cichego driftu w umowie API za pomocą type’ów wywnioskowanych z przykładów i Zod — Dlaczego ręcznie napisane type’y TypeScript dla API third-party stają się przestarzałe, jak wywnioskowywanie type’ów i schematów Zod z rzeczywistych odpowiedzi pomaga, oraz w jaki sposób różnice między zrzutami obrazu ujawniają drift.
  • Zasady TypeScript: od pierwszej adnotacji po generiki i tryb strict — Strukturalne omówienie systemu type’ów TypeScript, od prymitywów i mechanizmów wywnioskowywania po zespoły rozróżniające, generiki, type’y pomocnicze oraz ustawienia strict w tsconfig.
  • Behawiry TypeScript, które zaskakują doświadczonych rozwojowców, i dlaczego — typowanie strukturalne, nadmierne sprawdzanie właściwości, użycie klucza as const, operatory satisfies, typy warunkowe i mapowane, a także zasady projektowania, które przekształcają je w bezpieczniejszy kod.