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 (is
, asserts
) 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')
Chcemy lepiej poznać Twoje potrzeby
Pomóż nam rozwijać nasze produkty!
Wypełnij krótką, anonimową ankietę. Zajmie ona niecałe 2 minuty. Znajdziesz w niej pytania dotyczące Ciebie i naszej marki.
Będziemy ogromnie wdzięczni za Twoje opinie.
Wypełnij ankietę