Do czego służą?
Podczas pracy z TypeScriptem, dosyć często można spotkać się z następującą sytuacją - utworzyliśmy jakiś typ (np. unię, obiekt, tablice), a teraz potrzebujemy na jego podstawie stworzyć kolejny typ obiektowy, którego kluczami mogą być przykładowo, elementy unii.
Drugim przypadkiem z którym spotkasz się często, jest potrzeba utworzenia typu, na podstawie istniejącej wartości. Zdarzają się wartości, które są stałe i w związku z tym nie posiadają osobnego typu, ponieważ opieramy się na inferencji.
W obu sytuacjach z pomocą przyjdą typy mapowane (mapped types). Z ich pomocą możemy tworzyć nowe typy obiektowe, bazujące na istniejących typach lub wartościach. Posiadają również sporo możliwości modyfikacji i dostosowywania tworzonego właśnie typu, do potrzeb programisty.
Zanim przejdziemy do samych typów mapowanych, musimy poznać kilka słów kluczowych, które będą bardzo często używane w czasie pracy z nimi.
keyof
Z jego pomocą możemy odczytać wszystkie klucze z typu obiektowego i utworzyć z nich unię literałów (w przypadku, w którym obiekt zwiera więcej niż jeden klucz) lub literał (w przypadku, gdy obiekt ma tylko jeden klucz).
W utworzonym typie, literałem może być zarówno string
, jak i number
. To jaką wartość otrzymamy, zależy od tego, jakim typem były klucze, w użytym obiekcie.
Stworzyliśmy dwa typy: Person oraz Answer. W przypadku pierwszego z nich otrzymamy unię literałów string
, ponieważ Person ma kilka kluczy, a każdy z nich jest stringiem. W drugim przypadku wynikiem jest unia literałów number
.
typeof
Ten operator występuje w natywnym JavaScript, jednak jego działanie w TypeScript jest nieco inne. To, jak zostanie on potraktowany zależy od miejsca użycia. Jeżeli zostanie on użyty w miejscu, gdzie spodziewamy się typu, to zostanie potraktowany przez TS nieco inaczej.
Jego działanie sprowadza się do zamieniania wartości na typ. Jest bardzo przydatny, kiedy na podstawie jakiejś wartości chcemy utworzyć typ, a co za tym idzie, jest bardzo często wykorzystywany w typach mapowanych.
in
W działaniu przypomina pętlę for...in
. Jego zastosowanie ogranicza się jedynie do typów mapowanych. Zapis wygląda również podobnie: K in keyof SomeObjectType
. Mimo, że na początku nie będzie on oczywisty, to należy on nim myśleć jak o pętli dla typów - K
to generyczny typ, pod który po kolei będzie podstawiony każdy typ z unii keyof SomeObjectType
.
Pierwszy typ mapowany
Skoro wiemy już jakich operatorów będziemy używać i znamy ich działanie, to czas najwyższy, żeby to wszystko złożyć w całość i napisać pierwszy typ mapowany.
Przykład, którym się posłużymy będzie możliwie prosty, co pozwoli się skupić na składni, a nie na przesadniej skomplikowanej logice kodu.
Przypuśćmy, że utworzyliśmy typ przechowujący dostępne imiona - w naszym przypadku będzie to unia literałów stringa: "Natalia" | "Alicja" | "Karolina"
. Na jej podstawie chcemy utworzyć nowy typ, a konkretnie obiekt, którego kluczami będą imiona z utworzonej wcześniej unii, a wartością typ User
(utworzymy go za chwilę).
Być może przyszło Ci już do głowy, że można tu także zastosować wbudowany typ generyczny Record
i osiągnąć zamierzony efekt (jeżeli nie spotkałeś się z nim wcześniej, to opisałem go w tym artykule). Masz rację, ale tak jak wspomniałem pierwszy przykład ma być możliwie prosty w celu zrozumienia składni. Tak więc do dzieła!
To teraz po kolei, co dzieję się w powyższym przykładzie:
Tworzymy typ
Name
przechowujący imionaTworzymy typ
User
, który ma być odzwierciedleniem użytkownika w naszej aplikacjiZa pomocą typu mapowanego tworzymy nowy typ obiektowy
Users
, którego struktura wygląda identycznie jak typuUsersWithoutMappedTypes
. Oba te typy wyglądają dokładnie tak samo, jednak z użyciem typu mapowanego zrobiliśmy to znacznie szybciej, a dodatkowo, jeżeliName
ulegnie zmianie, to automatycznie będzie to odwzorowane wUsers
. W przypadkuUsersWithoutMappedTypes
zmiany musiałbym wprowadzić ręcznie.
To teraz teoria dotycząca zapisu związanego z typami mapowanymi:
Podobnie jak typ obiektowy, tak samo typ mapowany tworzymy za pomocą nawiasów klamrowych
{ ... }
Wewnątrz nawiasów kwadratowych
[ ... ]
umieszczamy pętlę, a po:
typ, który zostanie przypisany do każdego kluczaPętlę wygląda następująco:
K in Keys
.K
to typ generyczny, dlatego jego nazwa jest dowolna, najczęściej jednak stosuje sięK
lub Key, natomiast keys tostring
,symbol
lubnumber
(lub unia tych typów)
Typ na podstawie wartości
Do tej pory nie użyliśmy jeszcze ani operatora keyof
, ani typeof
. Wprowadzimy je po kolei - zaczniemy od keyof, następnie typeof, a na samym końcu połączymy oba te operatory w jednym wyrażeniu.
Żeby nieco uprościć ten przykład posłużymy się utworzonym wcześniej typem Users
(patrz przykład wyżej). Na jego podstawie utworzymy kolejny typ obiektowy, jednak zamiast przypisywać ponownie User
jako typ wartości, użyjemy tym razem czegoś innego.
z pomocą operatora keyof
odczytaliśmy klucze z typu Users
, a następnie użyliśmy ich do utworzenia nowego typu obiektowego. Ten zapis można nieco skrócić, w przykładzie powyżej celowo rozpisałem go na osobne kroki, jednak częściej spotkasz się z takim zapisem:
Dzięki temu podejściu nie musimy tworzyć pośredniego typu, który będzie przechowywał odczytane klucze. Zamiast tego klucze odczytujemy dopiero w momencie, w którym tworzymy typ mapowany.
Czas dołożyć do tego wszystkiego operator typeof
i utworzyć typ na podstawie wartości.
Podobnie jak poprzednio, tu również nie musimy tworzyć pośredniego typu UserKey
. Możemy zastosować wyrażenie keyof typeof users
bezpośrednio w typie mapowanym.
Modyfikatory
Podczas tworzenia typu mapowanego można również dodać, lub usunąć modyfikatory readonly
oraz ?
(opcjonalna wartość). Jak to zrobić?
Jeżeli chcemy usunąć istniejący modyfikator, to wystarczy dodać znak -
przed danym modyfikatorem: -readonly
, -?
. W przypadku dodawania możemy również wstawić znak +
przed modyfikatorem, jednak nie jest to konieczne.
Przekształcenia
Jest to dość zaawansowany temat, jednak w praktyce bardzo często wykorzystywany.
Przkeształcenia (być może to nie najlepsze tłumaczenie - po angielsku ten termin to remapping) pozwalają na modyfikację kluczy i dostosowanie ich do tworzonego właśnie typu.
Na początek coś stosunkowo prostego. Mamy utworzony obiekt, który jest niezmienny, zatem całość jest zapisana wielkimi literami w celu oznaczenia, że jest to wartość, która nigdy nie ulegnie zmianie w czasie działania programu. Na jego podstawie utworzymy typ, jednak nie chcemy żeby klucze były zapisane wielkimi literami. Z pomocą przychodzi remapping kluczy.
Nowością w tym zapisie jest wykorzystanie słowa kluczowego as
w celu przekształcenia klucza. Składnia wygląda następująco: K in Keys as OtherType
. Zapis ten oznacza, że chcemy zmienić typ K
na typ, który występuje po słowie kluczowym as
.
Zatem K
zostanie zamienione na OtherType
. Może się tam znaleźć dowolny typ, ale w praktyce najczęściej są to wariacje typu K
. Jeżeli jednak jest taka potrzeba, to możesz równie dobrze wstawić w to miejsce unknown
.
Zobaczmy jeszcze jeden przykład remappingu, tym razem nieco bardziej rozbudowany.
Jest to na tyle skomplikowany przykład, że warto rozbić go na części pierwsze:
Z użyciem składni
K in keyof User
, tworzymy pętlę, zatem podK
zostanie podstawiony każdy klucz po koleiZ pomocą remappingu i słowa kluczowego
as
przekształcamy każdy z kluczyWykorzystujemy do tego wbudowany w język generyczny typ
Exclude
, który usuwa z przekazanego typu (lub unii) wskazaną wartość - w naszym przypadku jest toid
.Do
Exclude
zawsze przekazujemy generyczny typK
pod którym znajduje się każdy klucz po kolei. W momencie, gdy podK
znajduje się wartość"id"
, to transformacja wygląda tak:... as Exclude<"id", "id">
, a wynikiem tego działania jest typnever
(nie można do niego przypisać żadnej wartości).
Jeżeli jakikolwiek z kluczy zostanie przekształcony na typ never
, to zostanie on usunięty z nowo utworzonego obiektu. Zatem zapis ... as never
usuwa dany klucz. Podobne zachowanie występuje w przypadku unii typów. Jeżeli jeden z jej elementów to never
, to zostanie on po prostu usunięty.
Dzięki temu mechanizmowi możemy użyć Exclude
do usuwania wybranych kluczy z typu.
Podsumowanie
Typy mapowane są dynamiczne - tworzone na podstawie innych typów. Mają wiele zastosowań i potrafią być bardzo rozbudowane, szczególnie kiedy zastosujemy wiele przekształceń (a na dodatek dodamy do tego przeróżne warunki z wykorzystaniem typów warunkowych), ale dzięki temu są również uniwersalne jeżeli chodzi o zastosowanie.
Na początku ich zastosowanie w praktyce może być trudne, a składnia nieintuicyjna, ale z czasem stanie się zupełnie naturalna (jak większość zagadnień związanych z programowaniem, kluczem jest wytrwałość i cierpliwość).
Ich zwięzła składnia pozwala na tworzenie bardzo rozbudowanych typów przy niewielkim nakładzie pracy. Jeżeli spotkasz się z sytuacją w której tworzysz typ obiektowy na podstawie jakiegoś innego typu, to powinien być to jasny znak, że warto zastanowić się nad wykorzystaniem typu mapowanego. Zyskujesz wtedy gwarancję, że oba te typy będą ze sobą spójne (wprowadzenie zmiany w typie bazowym wprowadzi ją również w typie mapowanym).