Zaawansowany TypeScript z prawdziwym przykładem (część 2)

Opublikowany: 2020-11-20

Czas kontynuować (i, miejmy nadzieję, zakończyć) nasz samouczek TypeScript. Jeśli przegapiłeś poprzednie posty, które napisaliśmy na temat TypeScript, oto one: nasze wstępne wprowadzenie do TypeScript i pierwsza część tego samouczka, w której wyjaśniam przykład JavaScript, z którym pracujemy, oraz kroki, które podjęliśmy, aby go częściowo poprawić .

Dzisiaj zakończymy nasz przykład, uzupełniając wszystko, czego jeszcze brakuje. W szczególności najpierw zobaczymy, jak tworzyć typy, które są częściowymi wersjami innych istniejących typów. Następnie zobaczymy, jak poprawnie wpisać akcje sklepu Redux przy użyciu unii typów, i omówimy zalety oferowane przez unie typów. I na koniec pokażę, jak stworzyć funkcję polimorficzną, której typ zwracany zależy od jej argumentów.

Krótki przegląd tego, co zrobiliśmy do tej pory…

W pierwszej części samouczka wykorzystaliśmy (część) sklepu Redux, który zaczerpnęliśmy z Nelio Content jako naszego roboczego przykładu. Wszystko zaczęło się od zwykłego kodu JavaScript, który musiał zostać ulepszony przez dodanie konkretnych typów, które uczyniły go bardziej niezawodnym i zrozumiałym. I tak na przykład zdefiniowaliśmy następujące typy:

 type PostId = number; type Day = string; type Post = { id: PostId; title: string; author: string; day: Day; status: string; isSticky: boolean; }; type State = { posts: Dictionary<PostId, Post>; days: Dictionary<Day, PostId[]>; };

co pomogło nam szybko zrozumieć, z jakimi informacjami współpracuje nasz sklep. W tym konkretnym przypadku, na przykład, widzimy, że stan naszej aplikacji przechowuje dwie rzeczy: listę posts (które zindeksowaliśmy poprzez ich PostId ) oraz strukturę o nazwie days , która w określonym dniu zwraca listę identyfikatory poczty. Możemy również zobaczyć atrybuty (i ich specyficzne typy), które znajdziemy w obiekcie Post .

Po zdefiniowaniu tych typów zmodyfikowaliśmy wszystkie funkcje naszego przykładu, aby z nich korzystać. To proste zadanie przekształciło nieprzejrzyste sygnatury funkcji JavaScript:

 // Selectors function getPost( state, id ) { ... } function getPostsInDay( state, day ) { ... } // Actions function receiveNewPost( post ) { ... } function updatePost( postId, attributes ) { ... } // Reducer function reducer( state, action ) { ... }

do zrozumiałych sygnatur funkcji TypeScript:

 // Selectors function getPost( state: State, id: PostId ): Post | undefined { ... } function getPostsInDay( state: State, day: Day ): PostId[] { ... } // Actions function receiveNewPost( post: Post ): any { ... } function updatePost( postId: PostId, attributes: any ): any { ... } // Reducer function reducer( state: State, action: any ): State { ... }

Funkcja getPostsInDay jest bardzo dobrym przykładem tego, jak bardzo TypeScript poprawi jakość Twojego kodu. Jeśli spojrzysz na odpowiednik JavaScript, naprawdę nie wiesz, co ta funkcja zwróci. Jasne, jej nazwa może wskazywać na typ wyniku (czy jest to może lista postów?), ale musisz spojrzeć na kod źródłowy funkcji (i prawdopodobnie także akcje i reduktory), aby mieć pewność (w rzeczywistości jest to lista identyfikatory pocztowe). Można poprawić tę sytuację, lepiej nazywając rzeczy (na przykład getIdsOfPostsInDay ), ale nie ma nic lepszego niż konkretne typy, które rozwieją wszelkie wątpliwości: PostId[] .

Więc teraz, gdy jesteś na bieżąco z obecnym stanem rzeczy, nadszedł czas, aby przejść dalej i naprawić wszystko, co pominęliśmy w zeszłym tygodniu. W szczególności wiemy, że musimy wpisać atrybuty attributes funkcji updatePost i musimy zdefiniować typy, jakie będą miały nasze akcje (zauważ, że w module reducer , atrybut action w tej chwili jest typu any ).

Jak wpisać obiekt, którego atrybuty są podzbiorem innego obiektu

Rozgrzejmy się, zaczynając od czegoś prostego. Funkcja updatePost generuje akcję, która sygnalizuje naszą intencję aktualizacji pewnych atrybutów danego identyfikatora postu. Oto jak to wygląda:

 function updatePost( postId: PostId, attributes: any ): any { return { type: 'UPDATE_POST', postId, attributes, }; }

a oto jak akcja jest wykorzystywana przez reduktor do aktualizacji posta w naszym sklepie:

 function reducer( state: State, action: any ): State { // ... switch ( action.type ) { // ... case 'UPDATE_POST': if ( ! state.posts[ action.postId ] ) { return state; } const post = { ...state.posts[ action.postId ], ...action.attributes, }; return { ... }; } // ... }

Jak widać, reduktor przeszukuje post w sklepie i jeśli się tam znajduje, aktualizuje jego atrybuty nadpisując je tymi zawartymi w akcji.

Ale jakie dokładnie są attributes akcji? Cóż, są one wyraźnie czymś, co wygląda podobnie do Post , ponieważ mają zastąpić atrybuty, które możemy znaleźć w poście:

 type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Post; };

ale jeśli spróbujemy tego użyć, zobaczymy, że to nie działa:

 const post: Post = { id: 1, title: 'Title', author: 'Ruth', day: '2020-10-01', status: 'draft', isSticky: false, }; const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, };

ponieważ nie chcemy, aby attributes były samym Post ; chcemy, aby był to podzbiór atrybutów Post (tj. chcemy określić tylko te atrybuty obiektu Post , które będziemy nadpisywać).

Aby rozwiązać ten problem, wystarczy użyć typu narzędzia Partial :

 type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Partial<Post>; };

I to wszystko! Albo to jest?

Jawne filtrowanie atrybutów

Poprzedni fragment nadal jest wadliwy, ponieważ mogą wystąpić błędy w czasie wykonywania, których kompilator TypeScript nie sprawdza. Oto dlaczego: akcja sygnalizująca po aktualizacji ma dwa argumenty, identyfikator postu i zestaw atrybutów, które chcemy zaktualizować. Gdy mamy już gotową akcję, reduktor odpowiada za nadpisanie istniejącego posta nowymi wartościami:

 const post = { ...state.posts[ action.postId ], ...action.attributes, };

I to jest właśnie ta błędna część naszego kodu; możliwe, że atrybut postId akcji ma identyfikator x pots, a atrybut id w attributes ma inny identyfikator postu y :

 const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 2, author: 'Toni', }, };

Jest to oczywiście prawidłowa akcja, więc TypeScript nie powoduje żadnych błędów, ale wiemy, że tak nie powinno być. Atrybut id w attributes (jeśli występuje) oraz atrybut postId powinny mieć tę samą wartość, w przeciwnym razie mamy niespójną akcję. Nasz typ działania jest nieprecyzyjny, ponieważ pozwala nam określić sytuację, która powinna być niemożliwa… więc jak możemy to naprawić? Całkiem łatwo: po prostu zmień ten typ, aby ten scenariusz, który powinien być niemożliwy, stał się faktycznie niemożliwy.

Pierwsze rozwiązanie, o którym pomyślałem, to: usuń atrybut postId z akcji i dodaj identyfikator w attributes atrybutów:

 type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post>; }; function updatePost( postId: PostId, attributes: Partial<Post> ): UpdatePostAction { return { type: 'UPDATE_POST', attributes: { ...attributes, id: postId, }, }; }

Następnie zaktualizuj swój reduktor tak, aby używał action.attributes.id zamiast action.postId do wyszukiwania i zastępowania istniejącego posta.

Niestety to rozwiązanie nie jest idealne, bo attributes to „częściowy post”, pamiętasz? Oznacza to, że teoretycznie atrybut id może, ale nie musi znajdować się w obiekcie attributes . Jasne, wiemy, że tam będzie, ponieważ to my generujemy akcję… ale nasze typy są nadal nieprecyzyjne. Jeśli w przyszłości ktoś zmodyfikuje funkcję updatePost i nie upewni się, że attributes zawierają postId , wynikowa akcja będzie poprawna zgodnie z TypeScript, ale nasz kod nie zadziała:

 const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };

Tak więc, jeśli chcemy, aby TypeScript nas chronił, musimy być tak precyzyjni, jak to możliwe podczas określania typów i upewnić się, że uniemożliwiają one niemożliwe stany. Biorąc to wszystko pod uwagę, mamy do wyboru tylko dwie opcje:

  1. Jeśli mamy w akcji atrybut postId (tak jak to zrobiliśmy na początku), to obiekt attributes nie może zawierać atrybutu id .
  2. Jeśli natomiast akcja nie posiada atrybutu postId , to attributes muszą zawierać atrybut id .

Pierwsze rozwiązanie można łatwo określić za pomocą innego typu narzędzia, Omit , które pozwala nam stworzyć nowy typ poprzez usunięcie atrybutów z istniejącego typu:

 type UpdatePostAction = { type: 'UPDATE_POST'; postId: PostId, attributes: Partial< Omit<Post, 'id'> >; };

który działa zgodnie z oczekiwaniami:

 const workingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 1, author: 'Toni', }, };

W przypadku drugiej opcji musimy jawnie dodać atrybut id nad zdefiniowanym przez nas typem Partial<Post> :

 type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post> & { id: PostId }; };

co ponownie daje nam oczekiwany wynik :

 const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };

Typy Unii

W poprzedniej sekcji widzieliśmy już, jak wpisać jedną z dwóch akcji, które ma nasz sklep. Zróbmy to samo z drugą akcją. Wiedząc, że receiveNewPost wygląda tak:

 function receiveNewPost( post: Post ): any { return { type: 'RECEIVE_NEW_POST', post, }; }

jego typ można zdefiniować w następujący sposób:

 type ReceiveNewPostAction = { type: 'RECEIVE_NEW_POST'; post: Post; };

Łatwe, prawda?

Przyjrzyjmy się teraz naszemu reduktorowi: pobiera state i action (którego typu jeszcze nie znamy) i tworzy nowy State :

 function reducer( state: State, action: any ): State { ... }

W naszym sklepie dostępne są dwa różne rodzaje akcji: UpdatePostAction i ReceiveNewPostAction . Więc jaki jest typ argumentu action ? Jedno czy drugie, prawda? Gdy zmienna może akceptować więcej niż jeden typ A , B , C itd., jej typ jest unią typów. Oznacza to, że jego typem może być A , B lub C i tak dalej. Typy unii to typ, którego wartości mogą być dowolnego typu określonego w tej unii.

Oto jak nasz typ Action można zdefiniować jako typ związkowy:

 type Action = UpdatePostAction | ReceiveNewPostAction;

Poprzedni fragment kodu po prostu stwierdza, że Action może być wystąpieniem typu UpdatePostAction lub wystąpieniem typu ReceiveNewPostAction .

Jeśli teraz użyjemy Action w naszym reduktorze:

 function reducer( state: State, action: Action ): State { ... }

możemy zobaczyć, jak ta nowa wersja naszego kodu, która jest dobrze napisana, działa płynnie.

Jak typy unijne eliminują przypadki domyślne

„Poczekaj chwilę”, możesz powiedzieć, „poprzedni link nie działa płynnie, kompilator wywołuje błąd!” Rzeczywiście, zgodnie z TypeScript, nasz reduktor zawiera nieosiągalny kod:

 function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... } return state; //Error! Unreachable code }

Czekaj, co? Pozwól, że wyjaśnię, co się tutaj dzieje…

Utworzony przez nas typ związku Action jest w rzeczywistości typem związku dyskryminowanego. Rozróżniany typ unii to typ unii, w którym wszystkie jego typy mają wspólny atrybut, którego wartość może służyć do odróżnienia jednego typu od drugiego.

W naszym przypadku dwa typy Action mają atrybut type , którego wartości to RECEIVE_NEW_POST dla ReceiveNewPostAction i UPDATE_POST dla UpdatePostAction . Ponieważ wiemy, że Action jest koniecznie instancją jednej lub drugiej akcji, dwie gałęzie naszego switch obejmują wszystkie możliwości: albo action.type to RECEIVE_NEW_POST , albo to UPDATE_POST . Dlatego ostateczny return jest zbędny i będzie nieosiągalny.

Załóżmy zatem, że usuniemy ten return , aby naprawić ten błąd. Czy zyskaliśmy coś poza usunięciem niepotrzebnego kodu? Odpowiedź brzmi tak. Jeśli teraz dodamy nowy typ akcji w naszym kodzie :

 type Action = | UpdatePostAction | ReceiveNewPostAction | NewFeatureAction; type NewFeatureAction = { type: 'NEW_FEATURE'; // ... };

nagle instrukcja switch w naszym reduktorze nie będzie już obejmować wszystkich możliwych scenariuszy:

 function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... // case NEW_FEATURE is missing... } // return undefined is now implicit }

Oznacza to, że reduktor może niejawnie zwrócić undefined wartość, jeśli wywołamy ją za pomocą akcji typu NEW_FEATURE , a to jest coś, co nie pasuje do sygnatury funkcji. Z powodu tej niezgodności TypeScript skarży się i informuje nas, że brakuje nam nowej gałęzi do obsługi tego nowego typu akcji.

Funkcje polimorficzne ze zmiennymi typami zwracanymi

Jeśli dotarłeś tak daleko, gratulacje: nauczyłeś się wszystkiego, co musisz zrobić, aby ulepszyć kod źródłowy swoich aplikacji JavaScript przy użyciu TypeScript. A w nagrodę podzielę się z Wami „problemem”, na który natknąłem się kilka dni temu i jego rozwiązaniem. Czemu? Ponieważ TypeScript to złożony i fascynujący świat i chcę pokazać, do jakiego stopnia jest to prawdą.

Na początku całej przygody widzieliśmy, że jednym z selektorów, które mamy, jest getPostsInDay i jak jego zwracanym typem jest lista identyfikatorów postów:

 function getPostsInDay( state: State, day: Day ): PostId[] { return state.days[ day ] ?? []; }

nawet jeśli nazwa sugeruje, że może zwrócić listę postów. Zastanawiasz się, dlaczego użyłem tak mylącej nazwy? Cóż, wyobraź sobie następujący scenariusz: załóżmy, że chcesz, aby ta funkcja mogła albo zwracać listę identyfikatorów postów, albo zwracać listę rzeczywistych postów, w zależności od wartości jednego z jej argumentów. Coś takiego:

 const ids: PostId[] = getPostsInDay( '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( '2020-10-01', 'all' );

Czy możemy to zrobić w TypeScript? Oczywiście, że tak! Dlaczego inaczej miałbym o tym wspomnieć? Wystarczy zdefiniować funkcję polimorficzną, której wynik zależy od parametrów wejściowych.

Chodzi o to, że chcemy mieć dwie różne wersje tej samej funkcji. Należy zwrócić listę PostId , jeśli jednym z atrybutów jest identyfikator string . Drugi powinien zwrócić listę Post , jeśli ten sam atrybut jest string all .

Stwórzmy je oba:

 function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[] { // ... } function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[] { // ... }

Łatwe, prawda? ZŁO! To nie działa. Według TypeScript mamy „implementację zduplikowanej funkcji”.

Dobra, spróbujmy więc czegoś innego. Połączmy poprzednie dwie definicje w jedną funkcję:

 function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' = 'id' ): PostId[] | Post[] { if ( 'id' === mode ) { return state.days[ day ] ?? []; } return []; }

Czy to zachowuje się tak, jak chcemy? Obawiam się, że nie…

Oto, co mówi nam ta sygnatura funkcji: „ getPostsInDay to funkcja, która przyjmuje dwa argumenty, state i mode których wartościami mogą być id lub all ; jego zwracanym typem będzie albo lista PostId , albo lista Postów Post . Innymi słowy, poprzednia definicja funkcji nie określa nigdzie, że istnieje związek między wartością podaną argumentowi mode a typem zwracanym przez funkcję. A więc kod taki:

 const state: State = { posts: {}, days: {} }; const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );

jest prawidłowy i nie zachowuje się tak, jak byśmy tego chcieli.

OK, ostatnia próba. A co, jeśli połączymy naszą początkową intuicję, w której opisujemy konkretne sygnatury funkcji, z jedną, poprawną implementacją?

 function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[]; function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[]; function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' ): PostId[] | Post[] { const postIds = state.days[ day ] ?? []; if ( 'id' === mode ) { return postIds; } return postIds .map( ( pid ) => getPost( state, pid ) ) .filter( ( p ): p is Post => !! p ); }

Poprzedni fragment zawiera poprawną implementację funkcji, która działa, ale definiuje dwie dodatkowe sygnatury funkcji, które wiążą konkretne wartości w mode z typem zwracanym przez funkcję.

Przy takim podejściu ten kod jest prawidłowy:

 const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );

a ten nie:

 const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'all' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'id' );

Wnioski

W tej serii wpisów zobaczyliśmy, czym jest TypeScript i jak możemy go zastosować w naszych projektach. Typy pomagają nam lepiej dokumentować kod, zapewniając kontekst semantyczny. Co więcej, typy dodają również dodatkową warstwę bezpieczeństwa, ponieważ kompilator TypeScript dba o sprawdzenie, czy nasz kod pasuje do siebie poprawnie, tak jak robią to Legos.

W tym momencie masz już wszystkie niezbędne narzędzia, aby podnieść jakość swojej pracy na wyższy poziom. Powodzenia w tej nowej przygodzie!

Polecane zdjęcie Mike'a Kenneally'ego na Unsplash.