Zaawansowane typy w TypeScript

Pomimo iż wiele osób wciąż kojarzy JavaScript z animacjami na stronach internetowych, język ten wykorzystywany jest również po stronie serwera, w aplikacjach webowych, mobilnych czy desktopowych.
Szybki rozwój oraz coraz szersze spektrum zastosowań spowodowało zapotrzebowanie na dodatkowe możliwości, którymi dysponują inne języki programowania. Z tego powodu Microsoft wprowadził na rynek TypeScript stanowiący pewien nadzbiór JavaScriptu, umożliwiający m.in. statyczne typowanie zmiennych, programowanie obiektowe, czy typy generyczne.
Osoby mające doświadczenie w JavaScript zdają sobie sprawę z tego, jakie konsekwencje może nieść za sobą brak typowania zmiennych. Przykładowo podanie jako argument zmiennej typu string
zamiast number
w prostym działaniu:
spowoduje konwersję wartości liczbowych do ciągu znaków i ich konkatenację (otrzymamy złączone ze sobą dwa stringi). Metoda addNumbers
może przyjąć argumenty jakiegokolwiek typu. JavaScript nie „domyśli się”, że chcemy dodać do siebie dwie liczby.
W JavaScript czeka na nas sporo, często zaskakujących pułapek. Dużym minusem jest brak możliwości wyłapania błędu przy kompilacji. Niestety wszystko poprawnie się skompiluje, ponieważ argumenty a
i b
w naszej funkcji addNumbers
nie mają podanego typu. Właśnie tu z pomocą wkracza TypeScript, w którym jeżeli zatypujemy sobie a
i b
, a następnie podamy inny typ, otrzymamy błąd już przy kompilacji.
Argument of type 'string' is not assignable to parameter of type 'number'.
Jeżeli cały kod aplikacji jest napisany w TypeScript, to problem z typami teoretycznie nie powinien mieć miejsca (pod warunkiem, że nie używamy namiętnie typu any, co osobiście szczerze odradzam).
W TypeScript mamy oczywiście możliwość tworzenia własnych typów. Nie musimy się ograniczać do tych podstawowych. Często w tym celu tworzymy interfejsy, dzięki którym możemy definiować strukturę obiektu. Aby usprawnić sobie pracę oraz wyeliminować w pewnym stopniu kopiowanie kodu, warto poznać Utility Types. TypeScript zawiera kilkanaście specjalnych typów, dzięki którym możemy tworzyć nowe z już istniejących.
Zdefiniujmy przykładowy interfejs Patient
, na podstawie którego utworzymy wiele innych nowych typów.
1. Pick<Type, Keys> - tworzy nowy typ z wyłącznie wybranymi polami Keys
Wyobraźmy sobie sytuację, w której chcemy zaktualizować dane adresowe pacjenta. Potrzebujemy więc stworzyć obiekt jedynie z polem address
oraz id
. Pozostałe pola - firstName
i lastName
pozostają bez zmian. Gdyby nowo stworzony obiekt był typu Patient
, w którym wszystkie pola są wymagane musielibyśmy nadmiarowo podać firstName
oraz lastName
. Szczególnie problematyczne okazałoby się to w przypadku obiektu z wieloma właściwościami – wtedy najprawdopodobniej stworzony by został nowy typ na potrzeby tego przypadku. Nie jest to jednak konieczne. Możemy skorzystać z istniejącego już typu:
Stworzony nowy typ posiada więc jedynie pola id
i address
. Oczywiście w tym przypadku, jak i w kolejnych poniżej, możemy od razu zatypować sobie obiekt, nie ma potrzeby tworzenia aliasu typu.
W przypadku nie podania wymaganego id
lub address
od razu otrzymamy błąd.
Property 'address' is missing in type '{ id: number; }' but required in type 'Pick<Patient, "address" | "id">'
2. Partial<Type> - zwraca typ Type z ustawionymi wszystkimi polami jako opcjonalne
Całkiem często zdarza się, że po prostu nie ma potrzeby podania wszystkich właściwości. Jak wcześniej, potrzebowaliśmy jedynie pola id
oraz address
. Prostszym sposobem niż Pick
jest ustawienie wszystkich pól jako opcjonalne. W przypadku naszego interfejsu Patient
utworzony nowy typ PartialPatient
nie ma ani jednego wymaganego pola. W takiej sytuacji trzeba jednak pamiętać, aby podać wszystkie pola, których np. oczekuje serwer. Nie zaktualizujemy już danych adresowych pacjenta, jeżeli zapomnimy o podaniu jego id.
3. Omit<Type, Keys> - działa w sposób odwrotny do Pick, czyli usuwa wybrane pola Keys
W takim przypadku nasz nowy typ PatientWithoutAddress
posiada id
, firstName
oraz lastName
.
4. Required<Type> - odwrotność wspomnianego wcześniej Partial – ustawia wszystkie pola jako wymagane.
Bardzo przydatny typ. W kombinacji z omawianym wcześniej Pick
możemy stworzyć typ z jednym wymaganym polem id
.
5. Readonly<Type> - do każdej właściwości dodaje readonly
Próba przypisania wartości do któregoś z pól skutkować będzie wystąpieniem błędu.
Cannot assign to 'id' because it is a read-only property.
Ponadto w TypeScript istnieją również dodatkowe dwa typy - Intersection Typ
oraz Union Type
, dzięki którym również możemy stworzyć nowy typ. Intersection Type
to sposób na łączenie kilku typów w jeden. Używamy w tym celu znaku &
, podając, które typy nas interesują. Powstały w ten sposób typ posiada właściwości kilku typów na raz. Union Type
z kolei pozwala na opisanie typu, który jest jednym z kilku podanych. Typy oddzielamy za pomocą |
. Opisany poniżej nowy typ IntersectionType
posiada wszystkie właściwości z Patient
i User
, natomiast typ Person
jest typu Patient
lub User
.
Na koniec Utility Types w wersji generycznej. Dobrym podejściem jest wytwarzanie kodu, który możemy użyć w wielu miejscach w naszej aplikacji. Błędną i niewydajną praktyką w programowaniu jest kopiowanie i wklejanie tych samych fragmentów w wielu miejscach. Spróbujmy połączyć wszystkie sposoby wcześniej opisane, tworząc nowy typ, z którego będziemy mogli skorzystać w wielu różnych przypadkach.
Wykorzystujemy tu Partial
, Omit
, Required
oraz Intersection Type
, jednak już nie jedynie dla typu Patient
jak wcześniej, ale dla jakiekolwiek zadanego. Co właściwie powstanie z takiej kombinacji?
W pierwszej części Partial<Omit<T, K>>
usuwa właściwość K
z T
i pozostałe pola ustawia jako opcjonalne. W drugiej części Required<Pick<T, K>>
sprawia, że zadana właściwość K
ustawiana jest jako wymagana. Połączenie obu skutkuje stworzeniem typu z jednym wymaganym K
i wszystkimi pozostałymi polami opcjonalnymi. Możemy tu użyć stworzonego wcześniej Patienta (ale może to być tak naprawdę jakikolwiek typ) i uzyskamy poniższy rezultat
Podsumowanie
Oczywiście to nie wszystkie z dostępnych typów w TypeScript. Z ciekawszych wymienić mogę jeszcze Record<K,T>
czy NonNullable
, lecz do dalszej lektury polecam oficjalną dokumentację. Znajomość możliwości, jakimi dysponujemy w TypeScript, pozwala nam wytwarzać lepszy, czytelniejszy kod, który łatwiej jest też utrzymać.