Zaawansowane typowanie w TypeScript
Z tego artykułu dowiesz się, czym są i jak wyglądają trzy ważne zagadnienia związane z typami generycznymi w TypeScript: Conditional types, Mapped types i Template Literal types. Przedstawię każde zagadnienie na przykładach, żeby je lepiej zilustrować.
Jednym z kluczowych elementów TypeScript są typy generyczne, które pozwalają na definiowanie i używanie typów w sposób bardziej elastyczny. W obrębie typów generycznych znajdują się trzy ważne zagadnienia: Conditional types, Mapped types i Template Literal types. Z tego artykułu dowiesz się, czym są i jak wyglądają, a wszystko to zostanie oparte na przykładach.
Conditional types
Conditional types, to funkcjonalność pozwalająca na zdefiniowanie typów, które zależą od warunku. Do definiowania typów warunkowych używane jest słowo kluczowe extends. Reszta wyrażenia wygląda tak, jak w operatorze warunkowym, czyli po znaku ? wartość dla prawdy oraz po znaku : wartość dla nieprawdy. Poniżej prosty przykład, w którym sprawdzamy, czy przekazany typ jest liczbą.
W tym przypadku, jeśli podany typ generyczny T rozszerza typ number, to zwrócone zostanie true. W przeciwnym razie zwrócone zostanie false.
Tak jak operatory warunkowe, conditional types można zagnieżdżać, czyli tworzyć typy, które zależą od innych typów warunkowych.
Typ NestedExample jest zależny od dwóch interfejsów — Dog oraz Animal. W pierwszym warunku sprawdzamy, czy interfejs Dog rozszerza Animal. Jest to prawdą, dlatego przechodzimy do kolejnego warunku, w którym sprawdzamy, czy RegExp rozszerza Animal. Tym razem warunek jest nieprawdziwy, dlatego wartość, jaką przyjmie NestedExample to boolean.
Typy w TypeScript są statyczne, co oznacza, że każda wartość jest znana w momencie pisania kodu. Jednak, w przypadku typów warunkowych, może być trudno określić typ na podstawie danego warunku. Z pomocą przychodzi słowo kluczowe infer, które odnosi się do mechanizmu wnioskowania typów. Inferencja pozwala na automatyczne ich wnioskowanie na podstawie wartości przypisywanych do zmiennych lub zwracanych przez funkcje. W praktyce oznacza to, że nie zawsze trzeba jawnie deklarować typy w kodzie. Jeśli wartość przypisywana do zmiennej ma określony typ, TypeScript będzie w stanie wywnioskować go i automatycznie przypisać. Dzięki infer możemy skorzystać z tego mechanizmu w trakcie tworzenia conditional types.
W tym przykładzie, w ReturnType sprawdzamy czy typ generyczny T jest funkcją i za pomocą infer zapisujemy w R typ jaki jest przez nią zwracany. Jeśli warunek jest prawdziwy, dostaniemy typ, który dostarcza przekazana funkcja (Example1 - void), w przeciwnym wypadku dostaniemy any (Example2).
Mapped types
Dzięki mapped types można dynamicznie tworzyć nowe typy, bazując na istniejących. W TypeScript mamy do dyspozycji kilka wbudowanych mapped types, np. Partial, Pick czy Capitalize. Oczywiście, można też tworzyć swoje własne.
Mechanizm mapowania polega na iterowaniu po właściwościach danego typu i modyfikacji ich według określonej reguły. Do iteracji po właściwościach można użyć operatora keyof. Zwraca on zbiór kluczy danego typu.
Typ AppConfigKeys będzie zawierał nazwy kluczy interfejsu AppConfig — layout i username.
Dodatkowo, do tworzenia mapped types można wykorzystać dwa słowa kluczowe in i as. Dzięki nim możemy modyfikować poszczególne klucze oraz ich właściwości.
W powyższym przykładzie zdefiniowaliśmy interfejs Messenger, który ma dwie metody: sendText oraz sendFile. Na jego podstawie stworzony został AsyncMessenger, który wykorzystuje typ Async przekształcający otrzymany typ generyczny T na typ zawierający takie same klucze (pod Property kryją się ich nazwy), ale dozwoloną wartością będzie funkcja zwracająca Promise<void>. Dlatego AsyncMessenger posiada takie same nazwy właściwości jak interfejs Messenger, jednak, zamiast funkcji zwracających void zwracać będą Promise<void>.
Gdybyśmy jednak chcieli zmienić trochę nazwy kluczy np. na takie zaczynające się wielką literą, możemy to zrobić za pomocą wcześniej wspomnianego as.
Ponownie definiujemy interfejs Messenger i typ AsyncMessenger, który jest zbudowany na jego podstawie oraz typ Async. Różnica jest taka, że zamiast niezmienionych nazw kluczy, jak w poprzednim przykładzie, otrzymamy klucze zaczynające się wielką literą — SendText i SendFile. Tak jak wcześniej, pod Property będą się znajdować nazwy kluczy, jednak tym razem zostały one opakowane w Capitalize. Jest to wbudowany typ w TypeScript, przyjmujący string i zmieniający jego pierwszą literę na dużą. Ponieważ klucze w obiektach niekoniecznie muszą być stringiem, musimy upewnić kompilator, że Property nim jest (& string).
Template Literal types
Template Literal types pozwala na tworzenie typów złożonych z łańcuchów znaków przy użyciu specjalnych operatorów i składni, wyglądającej tak jak template literals w JavaScript.
Podstawowym elementem składni template literal types jest backtick (`), czyli znak odwróconego apostrofu otaczającego łańcuch znaków. Przykładowo, deklaracja typu dla stałej przechowującej adres URL może wyglądać następująco:
W tym przypadku typ Url definiuje łańcuch znaków rozpoczynający się od protokołu https, zawierający dwa stringi oddzielone kropką. Stąd, zmienna url1 jest poprawna, a przy url2 TypeScript wskazuje błąd.
Template Literal types z łatwością można również użyć dla nazw kluczy w interfejsie.
W tym przypadku, dzięki użyciu template literal types, typ Person może zawierać dowolną liczbę pól phone zakończonych liczbą.
Więcej o Template Literal types możesz przeczytać w tym artykule.
Połączmy to wszystko!
Wszystkie omówione wcześniej zagadnienia, można bez problemu połączyć w trakcie tworzenia typów. W ostatnim przykładzie zdefiniujemy typ RequiredMessengerProperties, który zawiera tylko pola wymagane interfejsu Messenger. Następnie, na jego podstawie utworzymy typ FeatureFlags, definiujący dostępne feature flagi.
Interfejs Messenger definiuje strukturę obiektu zawierającego metody sendText, sendFile oraz opcjonalnie checkStatus. Metody te nie zwracają żadnych wartości (void).
RequiredFields definiuje nowy typ, który zawiera tylko te właściwości z typu generycznego T, które są oznaczone jako wymagane (Required to wbudowany w TypeScript typ, który definiuje pole jako obowiązkowe). Jest to możliwe dzięki użyciu operatora keyof, który zwraca typ składający się z nazw kluczy. Operator as pozwala na przefiltrowanie właściwości obiektu, które spełniają określone warunki. W tym przypadku wykorzystujemy never do usunięcia pól, które nie są obligatoryjne. RequiredMessengerProperties definiuje nowy typ, który zawiera tylko wymagane właściwości z interfejsu Messenger, czyli sendText oraz sendFile.
W FeatureOptions, na podstawie nazw właściwości typu generycznego T, generowane są nowe klucze zaczynające się od is, następnie pierwsza litera zostaje zmieniona na dużą, a na końcu zostaje doklejone Enabled. Każdy z kluczy będzie mógł otrzymać boolean jako wartość. Dzięki użyciu typu RequiredMessengerProperties w FeatureFlags, nazwy właściwości zostaną wygenerowane na podstawie wymaganych właściwości interfejsu Messenger — isSendTextEnabled i isSendFileEnabled.
Conditional types, Mapped types i Template Literal types to naprawdę potężne narzędzia udostępnione przez TypeScript. Każde z tych zagadnień umożliwia zaawansowaną pracę z typami, co pozwala nam na większą elastyczność oraz zmniejsza powtarzalność kodu.
Źródła:
- https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
- https://www.typescriptlang.org/docs/handbook/2/mapped-types.html
- https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
Masz pytania? Koniecznie napisz!
poznaj bliżej nas i nasze wartości