Mapped Types

Cover Image for Mapped Types
Krzysztof
Krzysztof

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

Loop and Type

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

Przydatne linki

UtilityTypes

MappedTypes