Praca z Type Guards w TypeScript

Jeśli znasz dobrze JavaScript oraz podstawy TypeScript, to praca z Type Guards jest dla Ciebie. Po lekturze tego artykułu bez problemu będziesz zawężał swoje typy tak, by bezpiecznie trzymały Cię za ręce, a Twój kod pachniał świeżo upranym prześcieradłem.

TL;DR:

Czym są Type Guards?

O co całe zamieszanie? Type Guards to mechanizm, który pozwala na sprawdzenie konkretnego typu zmiennej w trakcie wykonywania programu. Dzięki nim możemy wykonać odpowiednie działania lub skorzystać z funkcji, które są dostępne tylko dla danego typu. Type Guards są szczególnie przydatne w sytuacjach, gdy zmienna może mieć różne typy, a my chcemy wiedzieć, z jakim typem aktualnie mamy do czynienia. Co więcej, TypeScript już na poziomie pisania kodu trzyma nas za ręce i nie pozwala wykonać operacji, gdy typ w danym miejscu nie jest odpowiednio zawężony!

Wskoczmy już w uproszczony, lecz jednocześnie praktyczny przykład.

Programistyczne mięso 🍖

Jedna sprawa – pomijamy tutaj kwestię asynchroniczności w celu uproszczenia naszego kodu.

Wyobraźmy sobie, że chcemy wyświetlić w naszej aplikacji listę odcinków serialu Na wspólnej (tak, Na wspólnej podzielony jest na sezony!). Oto poglądowy model danych:

interface Episode {
  id: string;
  name: string;
  season: string;
}

Będziemy korzystać z udostępnionego API do pobierania odcinków, ale na szczęście mamy gotowe generyczne typy, które definiują strukturę odpowiedzi z serwera. Dzięki nim, będziemy mogli obsłużyć, zarówno poprawne odpowiedzi, jak i przypadki błędów, gdy nie uda się pobrać danych.

interface ApiOkResponse<T> {
  code: number;
  result: T;
}

interface ApiErrorResponse {
  code: number;
  error: string;
  message: string;
}

Na potrzeby tego przykładu przyjmijmy, że mamy zdefiniowane również dwie funkcje: getAllEpisodes do pobrania danych oraz handleApiError do obsługi błędu.

declare function getAllEpisodes(): ApiOkResponse<Episode[]> | ApiErrorResponse;

declare function handleApiError(errorResponse: ApiErrorResponse): void;

Skoro formalności mamy za sobą, to zapinamy pasy i lecimy.

Na start odpytujemy serwer i chcemy obsłużyć ewentualne błędy. Pojawia nam się problem.

const response = getAllEpisodes();

handleApiError(response); // ❌
// Error: Argument of type 'ApiErrorResponse | ApiOkResponse<Episode[]>' is not assignable
// to parameter of type 'ApiErrorResponse'

Powyższy błąd ma oczywiście sens, ponieważ handleApiError spodziewa się danych o konkretnym typie, a TypeScript na ten moment, jedyne co może nam zapewnić, to dostęp do wspólnych pól unii typów. Dlatego w naszym przypadku, jest to pole code.

response.code; // ✅
response.error; // ❌
response.metod; // ❌
response.result; // ❌

Pora w takim razie wykorzystać w praktyce mechanizm Type Guards, by TypeScript mógł poprawnie wywnioskować z jakim typem ma do czynienia, poprzez jego zawężenie. Spójrzmy na kod:

if ("error" in response && "message" in response) {
  // w tym bloku kodu nasza odpowiedź jest błędem
} else {
  // tutaj na pewno błędem nie jest
}

Przeprowadzamy zwykły test logiczny, który polega na identyfikacji unikalnej wartości jednego typu. Wspiera nas Duck Typing, co oznacza, że jeśli odpowiedź zawiera pola error i message, możemy stwierdzić, że nie jest to typ ApiOkResponse<Episode[]>. W rezultacie, wewnątrz danego bloku kodu, zawężamy typ do ApiErrorResponse, aby odpowiednio obsłużyć przypadki, gdy wystąpi błąd.

if ("error" in response && "message" in response) {
  // w tym bloku kodu nasza odpowiedź jest błędem
  handleApiError(response);
}

Podsumowując, w zawężaniu typów nie zawsze chodzi o to, by być pewnym, że dana zmienna jest konkretnego typu, lecz poprzez prosty test logiczny wykluczyć inne ewentualności.

Co z elastycznością takiego rozwiązania?

Idziemy dalej. Już wiem co sobie pomyśleliście, Wy fanatycy czystego kodu – wyciągnę sobie tę logikę z if’a do funkcji i będę sobie jej używał w innym miejscu, a do tego mój kod będzie bardziej czytelny i samodokumentujący się.

Wiedziałem, że nie trafiło na pierwszych lepszych, zacny pomysł 👏! Zróbmy to!

function isApiErrorResponse(response: any) {
  return (
    typeof response === "object" && "error" in response && "message" in response
  );
}

To teraz podmieniamy wyrażenie w if i jesteśmy w domu…

if (isApiErrorResponse(response)) {
  handleApiError(response);
}

Niestety nie jesteśmy, dostajemy na twarz kolejny raz błąd:

// Error: Argument of type 'ApiErrorResponse | ApiOkResponse<Episode[]>' is not assignable to parameter of type 'ApiErrorResponse'

Dzieje się to z prostego powodu, otóż wynikiem naszej funkcji będzie po prostu boolean, dla kompilatora nie mówi to nic konkretnego.

Jak widać, jego wnioskowanie jest ograniczone. Składnia TypeScript przychodzi nam jednak z pomocą, byśmy mogli doprecyzować co dokładnie określa zwracana wartość logiczna przez funkcję i pomóc kompilatorowi w działaniu.

function isApiErrorResponse(response: any): response is ApiErrorResponse {
  return (
    typeof response === "object" && "error" in response && "message" in response
  );
}

Ciało naszej funkcji kompletnie się nie zmieniło, dorzuciliśmy za to zwracany typ, używając słowa kluczowego is. Mówimy wprost kompilatorowi, ta zmienna jest tego typu, a zwracany wynik logiczny mówi nam czy to stwierdzenie jest prawdziwe czy fałszywe. Proste? Pewnie, że proste.

if (isApiErrorResponse(response)) {
  handleApiError(response); // działa 😍
}

Gwarancja typu albo śmierć

Co jeśli musimy być pewni, że w danym miejscu znajdzie się konkretny typ, choćby skały…

Możemy zrobić coś takiego:

if (isApiErrorResponse(response)) {
  handleApiError(response);
} else {
  throw new Error("Not an Api error response");
}

Problem z tym rozwiązaniem wiąże się z opakowaniem całego naszego if’a w blok try/catch

try {
  if (isApiErrorResponse(response)) {
    handleApiError(response);
  } else {
    throw new Error("Not an Api error response");
  }
} catch (exception) {
  // handle type assertion
}

Powiedzmy sobie szczerze, widzieliśmy już lepszy kod, a do tego, to dodatkowe zagnieżdzenia kodu 🤢

Na takie przypadki TypeScript też przygotował rozwiązanie, stwórzmy nową funkcję:

function assertsApiErrorResponse(
  response: any
): asserts response is ApiErrorResponse {
  if (
    !response ||
    !response.message ||
    !response.error ||
    typeof response.code !== "number" ||
    isNaN(response.code)
  ) {
    throw new Error("Not an Api error response");
  }
}

Nasza funkcja tym razem nic nie zwraca, za to rzuci błędem, jeśli okaże się, że wartość, którą sprawdzamy nie jest naszego docelowego tu czyli ApiErrorResponse. Dodatkowo sygnaturę zwracanego typu musimy poprzedzić słowem kluczowym asserts. Dzięki temu, nasz blok try/catch będzie dużo bardziej przyjazny, a kompilator skuma, o co nam chodzi.

try {
  assertsApiErrorResponse(response);
  handleApiError(response);
} catch (exception) {
  // handle type assertion
}

Cytując klasyka: Choose your fighter w zależności kontekstu zabawy z typami.

Protip na koniec Gdzie trzymać nasze funkcje sprawdzające w strukturze plików? Osobiście proponuję trzymać je tam, gdzie typ. Dodawane prefiksy (isasserts) do nazwy funkcji od razu mówi nam jakiego rodzaju zawężenie typu w niej stosujemy.

// apiErrorResponse.type.ts

export interface ApiErrorResponse {
  code: number;
  error: string;
  message: string;
}

export function isApiErrorResponse(
  response: any
): response is ApiErrorResponse {
  return "error" in response && "message" in response;
}

export function assertsApiErrorResponse(
  response: any
): asserts response is ApiErrorResponse {
  if (
    !response ||
    !response.message ||
    !response.error ||
    typeof response.code !== "number" ||
    isNaN(response.code)
  ) {
    throw new Error("Not an Api error response");
  }
}

Długi to był post, nie zapomnę go nigdy.

W sumie ciężko będzie zapomnieć o Type Guards w codziennej pracy z TypeScript. Dobrze mieć rozwiązania, które są intuicyjne w użyciu i zapewniają nam komfort pracy. Bo nadal lepiej dorzucić kilka linijek więcej do kodu, niż dostawać zwrotkę od testera z informacją, że: Cannot read properties of undefined (reading 'toUpperCase')

Programista Frontend z ponad 6-letnim doświadczeniem komercyjnym od zawsze związany z edukacją. Od ponad 3 lat łączy pracę programisty z rolą trenera w infoShare Academy.

Kamil Wojkowski - Programista Frontend