Mapped Types
Mapped Types
Pewnie wielokrotnie podczas korzystania z dobrodziejstw TypeScripta udało Ci się trafić na typy takie jak Partial, Required czy Readonly.
Ich idea jest stosunkowo prosta, ale czy wiesz, jak działają „pod spodem” lub jak utworzyć własny, oparty o mechanizm Mapped Types?
Być może zamiast nazwy wyżej do głowy przychodzi Ci pojęcie Utility Types. Często nazwy Mapped Types oraz Utility Types są używane wymiennie.
UtilityTypes możemy w krótkich słowach opisać jako typy wbudowane w Typescript. W praktyce są one zbiorem różnych MappedTypes więc wykorzystując techniki opisane w tym artykule stworzysz własne UtilityTypes
O MappedTypes wspomnieliśmy w artykule Type vs Interface. Jeżeli dalej czeka on w Twojej kolejce "do przeczytania", zajrzyj tutaj
Czym są Mapped Types?
Mapped Types lub inaczej typy zmapowane to generyki służące do mapowania (transformacji) pierwotnego typu na nowy.
Brzmi jak masło maślane?
W prostych słowach to typy budowane na podstawie innych, a ich głównym celem jest unikanie duplikowania kodu.
W rezultacie nowo utworzony typ będzie posiadał takie same właściwości jak jego poprzednik, jednakże będą one zmodyfikowane (np. staną się tylko do odczytu lub opcjonalne)
Spójrz na poniższy przykład:
type Car = { engine: string, price: number, numberOfSeats: number } type ReadonlyCar = Readonly<Car>
Powyższy jedno linijkowy zapis
type ReadonlyCar = Readonly<Car>
ma taki sam efekt jak długi i nieporęczny odpowiednik:
type ReadonlyCar = { readonly engine: string, readonly price: number, readonly numberOfSeats: number }
Co więcej, gdyby tych właściwości wymagających transformacji było znacznie więcej, mielibyśmy mnóstwo manualnej roboty do wykonania, a kod byłby też trudniejszy w utrzymaniu
Na powyższym przykładzie można również zauważyć, że Mapped Types to trochę tak jakby wykonanie operacji map() z JavaScript, ale w przestrzeni typów
Słowo kluczowe keyof
Podczas pracy z Mapped Types bardzo często zobaczysz słowo kluczowe keyof. Warto wiedzieć czym ono jest zamiast używać go po omacku.
Keyof jest operatorem, czyli wykonuje na wartościach jakieś operacje. W tym przypadku bierze typ obiektowy i tworzy na podstawie jego kluczy unię stringów lub numberów
Zobrazujmy to krótkim przykładem:
type Car = { engine: string, price: number, numberOfSeats: number } type CarKeys = keyof Car // "engine" | "price" | "numberOfSeats"
Ponownie posługując się analogią do funkcji z języka JavaScript to trochę tak jakby wykonać Object.keys() ale w przestrzeni typów
Tworzenie własnych mapowań
Podstawowa składnia w użyciu jest bardzo podobna do klasycznych generyków.
Bazuje ona na dwóch składowych:
- Pętla (podobna do for … in z JavaScript)
- Typ po transformacji
Transformacji możemy dokonywać poprzez prefixy + oraz –
Jak się pewnie domyślasz plus służy nam, gdy chcemy dodać jakiś modyfikator, a z kolei minus, gdy chcemy go usunąć
Warto mieć też na uwadze, że gdy pominiemy prefix to Typescript domyślnie użyje tam plusa
Zanim rzucisz się w wir tworzenia własnych typów przy użyciu mapowania warto sprawdzić co sam TypeScript ma nam do zaoferowania.
Wbudowane UtilityTypes
Oto lista kilku przydatnych, często wykorzystywanych rozwiązań:
- Readonly - wszystkie pola ustawia jako tylko do odczytu
- Partial - wszystkie pola ustawia jako opcjonalne
- Required - przeciwieństwo Partial, czyli wszystkie pola ustawia jako wymagane
- Record - konstruuje typ obiektu i pozwala określić jakiego typu będą wartości, a jakiego klucze
- Pick - tworzy nowy typ wybierając konkretne klucze z typu już istniejącego
- Omit - odwrotnie do Pick utility Omit pozwala nam stworzyć typ, bazując na już istniejącym typie, ale z pominięciem konkretnych kluczy
- ReturnType - wyciąga typ zwracany z przekazanej do niego funkcji
Czasem to jednak nie wystarczy i musimy "uszyć" rozwiązanie na miarę naszych potrzeb
Stwórzmy zatem własny UtilityType, który będzie dodawał modyfikator readonly oraz optional, a także każdą właściwość zamieniał na number:
type OptionalReadonlyNumbers<Type> = { readonly [P in keyof Type]?: number }
Wykorzystanie go w praktyce może wyglądać następująco:
type MyCustomObject = { first: string, second: number, third: boolean, } type MyTransformedCustomObject = OptionalReadonlyNumbers<MyCustomObject>
W tym momencie próba przypisania czegoś innego niż number do właściwości obiektu MyTransformedCustomObject zakończy się błędem:
const objectWithOnlyNumbers: MyTransformedCustomObject = { first: 12, second: 13, third: "test" }
TS2322: Type 'string' is not assignable to type 'number'.
Również przypisanie poza deklaracją zakończy się fiaskiem ze względu na modyfikator readonly:
objectWithOnlyNumbers.first = 123
Attempt to assign to const or readonly variable
Pewnie wpadł Ci w oko fakt, że użyliśmy konstrukcji in keyof podczas iteracji po przekazanym typie. Było to konieczne dlatego, że słowo kluczowe in potrzebuje do prawidłowego działania unię stringów, numberów lub symboli
Do utworzenia uni stringów doskonale sprawdził się zatem wcześniej poznany keyof
Zaawansowane mapowanie
Od wersji TypeScripta 4.1 możemy remapować klucze wewnątrz konstrukcji MappedType za pomocą słowa kluczowego as
Daje nam to szereg możliwości, których teraz nie będziemy szczegółowo omawiać, ale przedstawimy ogólny zarys tego, co możesz dzięki nim osiągnąć.
Aby lepiej zobrazować, jak wygląda remapowanie kluczy, wyobraźmy sobie stary system, w którym pojawiła się potrzeba oznaczenia niektórych typów jako przestarzałe.
Jesteśmy w stanie tego dokonać, dodając do każdego pola prefix „deprecated”. Jeśli takich pól jest wiele, to znowu mamy mnóstwo manualnej pracy więc warto skorzystać z tego, co oferują nam MappedTypes wzbogacone o remapowanie kluczy
Spójrz na poniższy przykład:
interface OldInterface { property1: number; property2: boolean; property3: string; } type MarkAsDeprecated<OldType> = { [K in keyof OldType as `deprecated_${string & K}`]: OldType[K]; }; type DeprecatedType = MarkAsDeprecated<OldInterface> type DeprecatedKeys = keyof DeprecatedType // "deprecated_property1" | deprecated_property2" | "deprecated_property3"
W bardzo łatwy sposób utworzyliśmy nowy UtilityType, który pomoże nam wykonać powtarzalną operację. Warto zwrócić uwagę, na fakt, że do remapowania możemy posłużyć się template literal z JavaScript i dzięki temu o wiele łatwiej skonstruować nową nazwę klucza
Dodatkowo słowem wyjaśnienia zapis string & K służy do tego, aby wykluczyć z mapowania wszystkie klucze, które nie są stringami. Jest to bardzo przydatne, gdy chcemy przemapować jedynie konkretny typ
Co więcej, jesteśmy w stanie również komponować inne UtilityTypes i dzięki temu np. odsiać całkowicie jakieś klucze:
type RemoveProperty2<T> = { [K in keyof T as Exclude<K, "property2">]: T[K] }; type WithoutProperty2Type = RemoveProperty2<OldInterface> type WithoutProperty2TypeKeys = keyof WithoutProperty2Type // "property1" | "property2"
W powyższym przykładzie pozbyliśmy się klucza property2 dzięki UtilityType Exclude, który zwraca typ never dla podanego property2
Podsumowując, do czego warto wykorzystać remapowanie:
- Dodawanie prefixów i sufixów do nazw kluczy
- Filtrowanie property
- Transformacja nazwy właściwości, zmiana wielkości liter, całkowita zmiana nazwy itp.
Podsumowanie
Mamy nadzieję, że po przeczytaniu tego wpisu będziesz świadomie korzystać z MappedTypes i tym samym ograniczysz duplikację kodu w swoich projektach.
Co więcej, mechanizm tworzenia własnych MappedTypes również nie jest taki straszny, jak go malują i z pewnością nie raz przytrafi się okazja, aby skonstruować "coś swojego"
To wszystko było zbyt skomplikowane?
Odwiedź repozytorium i wypróbuj techniki z tego artykułu samodzielnie, żeby lepiej utrwalić wiedzę => LINK