Tekst jest fragmentem książki „TypeScript na poważnie” mojego autorstwa. Jeśli artykuł Ci się podoba, to zachęcam Cię do kupienia tej pozycji – znajdziesz tam więcej praktycznych przykładów wraz z wyjaśnieniem teorii.
Co to są typy warunkowe?
Conditional types to możliwość wyrażania nieregularnych mapowań typów. Mówiąc prościej, pozwalają na zapisanie takiej transformacji, która wybiera typ w zależności od warunku. Myślę, że przeanalizowanie przykładu powinno wyjaśnić to, co właśnie wskazałem. Typ warunkowy zawsze przyjmuje następującą formę:
type R = T extends U ? X : Y;
gdzie T
, U
, X
i Y
to typy. Notacja … ? … : …
jest analogiczna do operatora trójargumentowego z JavaScriptu: przed znakiem zapytania podajemy warunek, w tym przypadku T extends U
, a następnie wynik, jeśli test zostanie spełniony (X
) oraz w przeciwnym wypadku (Y
). Takie wyrażenie oznacza, że jeśli warunek jest spełniony, to otrzymujemy typ X
, a jeśli nie, to Y
.
Przykładowe użycie conditional types
Na razie nie było zbyt wielu konkretów, więc spójrzmy na prosty przykład:
type IsBoolean<T> = T extends boolean ? true : false;
type t01 = IsBoolean<number>; // false
type t02 = IsBoolean<string>; // false
type t03 = IsBoolean<true>; // true
Pamiętaj, że t01
, t02
i t03
to typy, a nie wartości! Nasz conditional type IsBoolean
przyjmuje parametr i sprawdza, czy jest on boolem – odpowiada za to fragment extends boolean
. Wynikiem jest true
lub false
w zależności od tego, czy podany argument spełnia warunek. Co istotne, true
i false
tutaj to również typy (literały). Udało się nam stworzyć wyrażenie wykonywane warunkowo, które zwraca jeden typ w zależności od drugiego.
Typy warunkowe na unii
Na razie może wydawać się to mało przydatne, ale zaraz się przekonasz o użyteczności takich konstrukcji. Rzućmy okiem na inne użycie:
type NonNullable<T> = T extends null | undefined ? never : T;
type t04 = NonNullable<number>; // number
type t05 = NonNullable<string | null>; // string
type t06 = NonNullable<null | undefined>; // never
Do warunkowego NonNullable
podajemy parametr, a rezultatem jest ten sam typ, ale z usuniętymi null
i undefined
. Jeśli po ich wyeliminowaniu nic nie zostaje, to otrzymujemy never
. Czy zaczynasz dostrzegać, jakie to może być przydatne? Typy warunkowe pozwalają nam na tworzenie własnych, niejednokrotnie bardzo zaawansowanych mapowań opartych o warunki. Dokładne wyjaśnienie działania tego przykładu znajdziesz nieco dalej.
Zagnieżdżanie
Co ciekawe, conditional types możemy zagnieżdżać. Stwórzmy teraz generyk, który zwraca typ zawierający nazwę podanego parametru:
type TypeName<T> = T extends string
? 'string'
: T extends number
? 'number'
: T extends boolean
? 'boolean'
: T extends undefined
? 'undefined'
: T extends Function
? 'function'
: T extends Array<any>
? 'array'
: T extends null
? 'null'
: T extends symbol
? 'symbol'
: 'object';
Powyższe może się wydawać nieco długie i skomplikowane, a na pewno żmudne, ale efekt końcowy jest zadowalający:
type t07 = TypeName<string>; // 'string'
type t08 = TypeName<number>; // 'number'
type t09 = TypeName<boolean>; // 'boolean'
type t10 = TypeName<undefined>; // 'undefined'
type t11 = TypeName<function>; // 'function'
type t12 = TypeName<array>; // 'array'
type t13 = TypeName<null>; // 'null'
type t14 = TypeName<symbol>; // 'symbol'
type t15 = TypeName<object>; // 'object'
Warunkowe typy dystrybutywne – Distributive conditional types
Distributive conditional types to cecha typów warunkowych, która sprawia, że ich użycie na unii działa tak, jakbyśmy użyli warunku na każdym z komponentów wchodzących w jej skład osobno, a następnie wyniki połączyli ponownie unią. Brzmi skomplikowanie? Ależ skąd! Oba zapisy poniżej oznaczają dokładnie to samo:
type t16 = NonNullable<string | null | undefined>;
// string
type t17 = NonNullable<string> | NonNullable<null> | NonNullable<undefined>;
// string
Pierwsze użycie NonNullable
jest tak naprawdę interpretowane, jak to drugie. Stąd nazwa: komponenty z unii podanej jako parametr są rozdystrybuowane pomiędzy wiele użyć typu warunkowego i każdy z nich sprawdzany jest osobno. Ten podział jest szczególnie wyraźnie widoczny, gdy rezultat jest generykiem:
type Ref<T> = { current: T };
type RefVal<T> = T extends number ? Ref<T> : T extends string ? Ref<T> : never;
type t18 = RefVal<string>; // Ref<string>;
type t19 = RefVal<string | number>;
// Ref<string> | Ref<number>;
Zwróć uwagę, że do t19
przypisana jest alternatywa dwóch typów Ref<string>
i Ref<number>
, a nie Ref<string | number>
! TypeScript dokonał podziału unii. Można też zaobserwować podobne zachowanie w TypeName
, a rezultat jest dokładnie taki, jak moglibyśmy tego oczekiwać:
type t20 = TypeName<string | number | number[]>;
// "string" | "number" | "array"
Zauważ, że TypeName
zadziałało, pomimo że nigdzie nie definiowaliśmy, w jaki sposób ma obsłużyć przypadek, gdy parametrem jest unia. Dzięki dystrybucji komponentów TypeScript rozbił nasz skomplikowany typ na mniejsze składowe, co bardzo ułatwiło nam zadanie.
Głównym zastosowaniem tej cechy typów warunkowych jest filtrowanie unii, a więc tworzenie nowej, z której usunięto część typów. Aby wyeliminować jakiś komponent z unii, należy w warunku zwrócić never
– tak, jak to zrobiliśmy w NonNullable
. Unia dowolnego typu i never
daje tylko ten typ:
type StringsOnly<T> = T extends string ? T : never;
type Result = StringsOnly<'abc' | 123 | 'ghi'>;
// "abc" | never | "ghi"
// czyli:
// "abc" | "ghi"
Można powiedzieć, że never
to element neutralny dla unii.
Przykład użycia
Do czego ww. rozwiązanie z conditional type może się nam przydać? Wyobraźmy sobie sytuację, że mamy typ jakiegoś modelu w API, który zawiera zarówno pola, jak i metody. Potrzebujemy taki obiekt zserializować jako JSON i wysłać do użytkownika, a wtedy nie będzie w nim funkcji i pozostaną wyłącznie dane. W związku z tym chcielibyśmy stworzyć taki typ, w którym będą wszystkie pola naszego modelu z pominięciem metod. Czy jest to możliwe? Zacznijmy od zdefiniowania modelu z dwoma własnościami i jedną funkcją:
type Model = {
name: string;
age: number;
save(): Promise<void>;
};
Mogłaby to też być klasa, jak np. przy wykorzystaniu biblioteki Sequelize albo TypeORM. Teraz chcielibyśmy otrzymać taki sam typ, ale bez save
. W tym celu musimy wykonać dwa kroki: pobrać nazwy tych pól, które nas interesują, a następnie stworzyć z nich obiekt. Użyjemy do tego mapowania typów, które dokładnie omówiłem w rozdziale 12 mojej książki „TypeScript na poważnie”:
type FieldsNames<T extends object> = {
[K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
type OnlyFields<T extends object> = {
[K in FieldsNames<T>]: T[K];
};
type ModelFields = OnlyFields<Model>;
// { name: string; age: number; }
Dzieje się tu bardzo dużo, więc omówmy oba typy krok po kroku. Zacznijmy od końca:
type ModelFields = OnlyFields<Model>;
Tę linijkę możemy zastąpić bezpośrednio rozwinięciem OnlyFields
, aby było nam łatwiej zrozumieć, co robimy:
type ModelFields = {
[K in FieldsNames<Model>]: Model[K];
};
Jest to mapowanie typu, które oznacza mniej więcej tyle, że dla każdego pola K
w typie FieldsNames<Model>
tworzymy własność o nazwie K
z typem Model[K]
. Najważniejsze więc, aby zrozumieć, co dzieje się w FieldsNames<Model>
. Zapiszmy w tym celu pomocniczy typ A
:
type A = {
[K in keyof Model]: Model[K] extends Function ? never : K;
}[keyof Model];
Jest to dokładnie ten sam conditional type, co w FieldsNames
, tylko zamiast T
podstawiłem nasz Model
. Idźmy dalej, możemy rozwinąć zapis keyof Model
do "name" | "age" | "save"
:
type A = {
[K in 'name' | 'age' | 'save']: Model[K] extends Function ? never : K;
}[keyof Model];
Następnym krokiem byłoby rozpisanie tych trzech pól osobno, bez sygnatury indeksu. K
zamieniam na kolejne nazwy:
type A = {
name: Model['name'] extends Function ? never : 'name';
age: Model['age'] extends Function ? never : 'age';
doSth: Model['save'] extends Function ? never : 'save';
}[keyof Model];
Krok po kroku
Teraz możemy odczytać typy pól kryjących się pod Model['name']
, Model['age']
oraz Model['save']
i ręcznie wstawić je w odpowiednie miejsca:
type A = {
name: string extends Function ? never : 'name';
age: number extends Function ? never : 'age';
save: (() => Promise<void>) extends Function ? never : 'save';
}[keyof Model];
Pozostaje nam już tylko odpowiedź na pytanie, czy te typy są funkcjami (extends Function
)? Jeśli tak, to zamiast typu wstawiamy to, co jest po znaku zapytania (czyli never
), a w przeciwnym wypadku to, co po dwukropku:
type A = {
name: 'name';
age: 'age';
save: never;
}[keyof Model];
Kolejnym krokiem jest odczytanie wartości z pól tak powstałego obiektu. Służy temu składnia obj[keyof obj]
, którą również dokładnie omawiam w mojej książce o TypeScripcie. W rezultacie:
type A = 'name' | 'age' | never;
// Czyli to samo, co 'name' | 'age'
Wróćmy do typu ModelFields
i podstawmy znaleziony przez nas element tej układanki:
type ModelFields = {
[K in 'name' | 'age']: Model[K];
};
Wiemy, że ten zapis oznacza tak naprawdę stworzenie dwóch pól w obiekcie:
type ModelFields = {
name: Model['name'];
age: Model['age'];
};
Ostatni krok to proste podstawienie:
type ModelFields = {
name: string;
age: number;
};
Krok po kroku odtworzyliśmy skomplikowaną pracę, którą normalnie wykonuje za nas kompilator TypeScripta. Przeanalizuj dokładnie i powoli powyższe, aby lepiej zrozumieć działanie typów warunkowych.
Ten sam efekt można uzyskać korzystając z typów biblioteki standardowej TypeScripta. W tym przypadku Pick
:
type ModelFields = Pick<Model, FieldsNames<Model>>;
Więcej informacji o tym i pozostałych typach wbudowanych w TS znajdziesz pod koniec książki „TypeScript na poważnie”.
Podsumowanie conditional types
Mam nadzieję, że ten artykuł nieco przybliży Ci tematykę i użyteczność typów warunkowych w TypeScripcie. W kolejnym wpisie z serii omówię opóźnione warunki (deferred conditional types) oraz niezwykle przydatne słowo kluczowe infer
służące do sterowania pracą kompilatora i wnioskowania typów, o które poprosimy!
Powyższy tekst powstał na bazie rozdziału mojej książki „TypeScript na poważnie”, do kupienia której gorąco Cię zachęcam. O samym procesie powstawania książki przeczytasz w tym wpisie: