12 07 2018 %281%29

W tym artykule będziemy eksperymentować z najnowszymi możliwościami TypeScript 2.8: Typami warunkowymi i mapującymi. Naszym celem jest utworzenie ogólnego typu, który zmodyfikuje istniejący interfejs i wyeliminuje z niego klucze, które nie spełniają pewnego warunku. TL;DR: Kod źródłowy i rozwiązanie.

Artykuł dotyka zaawansowanych technik, ale aby swobodnie go czytać nie musisz dogłębnie znać szczegółów systemu typowania. Wystarczy wiedzieć, że możesz stworzyć własną definicję typu, według której TypeScript wie, jak lekko zmodyfikować istniejący typ, aby powstał nowy. To jeden z przejawów jego kompletności Turinga.

Pomyśl o typie jak o funkcji. Definiujesz jaki typ jesteś w stanie zmodyfikować - na tej podstawie TypeScript przeprowadza obliczenia i zwraca nowy typ jako wynik. Być może słyszałeś/aś o Partial<Type> lub Pick<Type, Keys>? One działają właśnie w ten sposób.

📐 Określmy problem

Załóżmy, że mamy obiekt z danymi użytkownika. Zawiera różne grupy kluczy: ID, daty i funkcje. Jest olbrzymi. Rozrastał się przez lata, modyfikowany przez kilka zespołów, a może pochodzi z zewnętrznego API? Tak tak, wiem, to się nigdy nie zdarza.

Chcemy wydobyć tylko klucze danego typu, np. tylko te funkcje, które zwracają Promise, a nawet coś prostszego: klucze typu number.

Do stworzenia typu potrzebujemy nazwy i definicji. Umówmy się na: SubType<Base, Condition>.

Sprecyzowaliśmy dwa typy generyczne, które skonfigurują SubType:

  • Base - interfejs, który zamierzamy zmienić
  • Condition - dodatkowy typ, który określa klucze, które chcemy zachować w nowym typie

Input

Do celów testowych weźmy obiekt Person, który jest złożony z kluczy o różnych typach: string, number, Function. To ten nasz „olbrzymi” obiekt, który chcemy odfiltrować i uprościć.

interface Person {
    id: number;
    name: string;    
    lastName: string;
    load: () => Promise<Person>;
}


Spodziewany wynik

Weźmy przykład: SubType złożony z interfejsu Person i warunku string zwróci tylko te klucze z Person, które są typu string:

// SubType<Person, string> 

type SubType = {
    name: string;
    lastName: string;
}

 

📈 Rozwiązanie: Krok po kroku

Krok 1 - Punkt wyjścia 

Największym wyzwaniem jest znalezienie i usunięcie kluczy, które nie spełniają naszego warunku. Na nasze szczęście TypeScript 2.8 wprowadził typy warunkowe! Wykorzystamy je i zrobimy mały trik. Stworzymy dwa typy pomocnicze, które rozłożą część pracy na mniejsze fragmenty.

type FilterFlags<Base, Condition> = {
    [Key in keyof Base]: 
        Base[Key] extends Condition ? Key : never
};


Sprawdzamy warunek na każdym kluczu. Jeżeli typ klucza spełnia warunek, podmieniamy go na nazwę klucza. W przeciwnym wypadku oznaczamy go specjalnym typem
never, do którego nie możemy niczego przypisać. Ta cecha bezpośrednio pomoże nam w wyfiltrowaniu niechcianych kluczy.

Spójrzmy teraz na kod:

FilterFlags<Person, string>; // Step 1
FilterFlags<Person, string> = { // Step 2
    id: number extends string ? 'id' : never;
    name: string extends string ? 'name' : never;
    lastName: string extends string ? 'lastName' : never;
    load: () => Promise<Person> extends string ? 'load' : never;
}
FilterFlags<Person, string> = { // Step 3
    id: never;
    name: 'name';
    lastName: 'lastName';
    load: never;
}


Zauważ, że 'id'
 nie jest wartością, a jedynie bardziej precyzyjnym typem od stringa. Wykorzystamy to później. Różnica między string, a 'id':

const text: string = 'name' // OK
const text: 'id' = 'name' // ERR


Krok 2 - Lista kluczy danego typu

Najważniejsze mamy już za sobą. Kolejny cel to zebranie nazw kluczy, które przeszły naszą weryfikację. Dla SubType<Person, string> byłoby to 'name' | 'lastName'.

type AllowedNames<Base, Condition> = 
        FilterFlags<Base, Condition>[keyof Base]


Do kodu z kroku pierwszego dodajemy tylko jeden element:
[keyof Base]. Z podanych kluczy ten fragment wyodrębnia listę ich typów oraz pomija never (do których i tak nic się nie da przypisać).

type family = {
    type: string;
    sad: never;
    members: number;
    friend: 'Lucy';
}
family['type' | 'members'] // string | number
family['sad' | 'members'] // number (never is ignored)
family['sad' | 'friend'] // 'Lucy'


Jaki to ma związek z nazwami kluczy, które chcemy zebrać z interfejsu? Otóż, w pierwszym kroku zmodyfikowaliśmy interfejs tak, że typy kluczy reprezentują ich nazwy!

type FilterFlags = {
    name: 'name';
    lastName: 'lastName';
    id: never;
}
AllowedNames<FilterFlags, string>; // 'name' | 'lastName'


Jesteśmy już blisko rozwiązania. 

Mając już informacje o kluczach, które spełniają nasz warunek, jesteśmy gotowi na dokończenie implementacji nowego typu. Wykorzystamy do tego wbudowany w TypeScript typ Pick, który przeiteruje po kluczach z oryginalnego interfejsu i stworzy nowy interfejs tylko na dla tych kluczy, które wcześniej zdefiniowaliśmy.

type SubType<Base, Condition> = 
        Pick<Base, AllowedNames<Base, Condition>>


Pick
to wbudowany typ mapujący, w TypeScript od 2.1:

Pick<Person, 'id' | 'name'>; 
// equals to:
{
   id: number;
   name: string;
}



🎉Pełne rozwiązanie

Podsumowując: stworzyliśmy dwa typy wspierające implementację SubType:

type FilterFlags<Base, Condition> = {
    [Key in keyof Base]: 
        Base[Key] extends Condition ? Key : never
};
type AllowedNames<Base, Condition> = 
        FilterFlags<Base, Condition>[keyof Base];
type SubType<Base, Condition> = 
        Pick<Base, AllowedNames<Base, Condition>>;


Zauważ, że powyższy kod jest związany tylko z systemem typów i działa on tylko w czasie kompilacji. Jest usuwany zaraz po niej. 
Niektórzy wolą, gdy cała definicja typu jest w jednym wyrażeniu, proszę bardzo:

type SubType<Base, Condition> = Pick<Base, {
    [Key in keyof Base]: Base[Key] extends Condition ? Key : never
}[keyof Base]>;

 

🔥 Przykłady użycia

  1. Wyfiltrowanie JSON-a ze wszystkich kluczy oprócz prymitywnych (string, number):
type JsonPrimitive = SubType<Person, number | string>;
// equals to:
type JsonPrimitive = {
    id: number;
    name: string;
    lastName: string;
}
// Let's assume Person has additional address key
type JsonComplex = SubType<Person, object>;
// equals to:
type JsonComplex = {
    address: {
        street: string;
        nr: number;
    };
}


2. Stworzenie nowego typu, który zawiera dowolne funkcje:

interface PersonLoader {
    loadAmountOfPeople: () => number;
    loadPeople: (city: string) => Person[];
    url: string;
}
type Callable = SubType<PersonLoader, (_: any) => any>
// equals to:
type Callable = {
    loadAmountOfPeople: () => number;
    loadPeople: (city: string) => Person[];
}

 

🤔 Kiedy rozwiązanie się nie sprawdzi?

1. Myślałem, by za pomocą tego rozwiązania stworzyć podtyp Nullable. Jednak z powodu reguł przypisywania się typów, nie da się tego osiągnąć obecną implementacją (np. nie można przypisać string | null do null).

// expected: Nullable = { city, street }
// actual: Nullable = {}
type Nullable = SubType<{
    street: string | null;
    city: string | null;
    id: string;
}, null>


2. Rzeczywiste filtrowanie obiektów w czasie działania programu. Pamiętaj, że wszystkie typy są usuwane w czasie kompilacji i osiągnięty przez nas efekt nie ma związku z rzeczywistym obiektem. Jeśli chciałbyś/abyś wyodrębnić obiekt w ten sam sposób, musisz sam zadbać o odpowiedni kod JavaScriptowy.

Nie radziłbym również używać na takiej strukturze Object.keys(). Wynik w czasie wykonywania może być inny, niż przewidziany typ.

Podsumowując

Gratulacje! Dowiedziałeś/aś się dziś jak działają typy warunkowe i mapujące. Co więcej, rozwiązaliśmy praktyczny problem. Powiąza kilka typów w jeden to łatwa robota, ale jak odfiltrować klucze, których nie potrzebujesz? Teraz już wiesz, jak to zrobić. 💪

Moją ulubioną cechą TypeScript jest to, że jest genialnie prosty podczas startu nauki, ale niełatwo osiągnąć w nim pełną biegłość. Wciąż odnajduję nowe sposoby rozwiązywania problemów, które pojawiły się przy mojej codziennej pracy. Dla utrwalenia i poszerzenia wiedzy bardzo polecam poczytać o zaawansowanym typowaniu w dokumentacji.