Условные типы в TypeScript дают нам возможность определять определенные типы на основе логики, как и в других аспектах нашего кода. Они являются полезным инструментом для определения типов в TypeScript.
Они имеют знакомый формат, в котором мы пишем их как condition ? ifConditionTrue : ifConditionFalse
— это формат, который уже используется повсеместно в TypeScript и Javascript. Давайте рассмотрим, как они работают.
Как работают условные типы в TypeScript
Давайте рассмотрим упрощенный пример, чтобы понять, как это работает. Здесь значением может быть дата рождения (DOB) или возраст пользователя. Если это дата рождения, то тип должен быть строкой, а если возраст, то числом. Мы определим три типа: Dob
, Age
и UserAgeInformation
.
type Dob = string;
type Age = number;
type UserAgeInformation<T> = T extends number ? number : string;
Как уже упоминалось, Dob
будет строкой
, например 12/12/1942
, а Age
, должно быть число
, например 96
.
Когда мы определяли UserAgeInformation
, мы написали его следующим образом:
type UserAgeInformation<T> = T extends number ? number : string;
Где T
— аргумент для UserAgeInformation
. Мы можем передать сюда любой тип. Тогда мы скажем, что если T расширяет число
, то тип будет число
. В противном случае, это string
. По сути, мы хотим сказать, что если T
имеет тип number
, то UserAgeInformation
должна быть number
.
Затем мы можем передать Age
в userAgeInformation
, если мы хотим, чтобы это было число, и Dob
в, если мы хотим, чтобы это была строка:
type Dob = string;
type Age = number;
type UserAgeInformation<T> = T extends number ? number : string;
let userAge:UserAgeInformation<Age> = 100;
let userDob:UserAgeInformation<Dob> = '12/12/1945';
Комбинирование условных типов с keyof
Мы можем сделать еще один шаг вперед, проверяя, расширяет ли T
объект. Например, допустим, мы управляем бизнесом, который имеет два типа клиентов: лошади
и пользователи
. Хотя у User
есть адрес, у Horse
обычно есть только местоположение. Для каждого из них существуют свои форматы адресов, как показано ниже:
type User = {
age: number,
name: string,
address: string
}
type Horse = {
age: number,
name: string
}
type UserAddress = {
addressLine1: string,
city: string,
country: string,
}
type HorseAddress = {
location: 'farm' | 'savanna' | 'field' | 'other'
}
В будущем у нас могут быть и другие типы клиентов, поэтому мы можем проверить в общем случае, есть ли у T
свойство address
. Если да, используйте UserAddress
. В противном случае используйте HorseAddress
в качестве конечного типа:
type AddressComponents<T> = T extends { address: string } ? UserAddress : HorseAddress
let userAddress:AddressComponents<User> = {
addressLine1: "123 Fake Street",
city: "Boston",
country: "USA"
}
let horseAddress:AddressComponents<Horse> = {
location: 'farm'
}
Когда мы говорим T extends { address: string }
, мы проверяем, есть ли у T
свойство address
. Если да, то мы используем UserAddress
. В противном случае мы можем по умолчанию использовать HorseAddress
.
Использование T в условных возвратах
Мы можем даже использовать сам T
в условных возвратах. В этом примере, поскольку T
определен как User
, когда мы вызываем его (UserType<User>
), myUser
имеет тип User
, и требует полей, определенных в этом типе (age
, name
, address
):
type User = {
age: number,
name: string,
address: string
}
type Horse = {
age: number,
name: string
}
type UserType<T> = T extends { address: string } ? T : Horse
let myUser:UserType<User> = {
age: 104,
name: "John Doe",
address: "123 Fake Street"
}
Объединение типов при использовании T в выводах типов
Если мы передадим сюда объединенный тип, то каждый из них будет проверяться отдельно. Например, допустим, мы сделали следующее:
type UserType<T> = T extends { address: string } ? T : string
let myUser:UserType<User | Horse> = {
age: 104,
name: "John Doe",
address: "123 Fake Street"
}
myUser
, приведенное выше, фактически становится типом User | string
. Это потому, что хотя User
проходит условную проверку, Horse
не проходит — поэтому он возвращает строку.
Если мы изменим T каким-либо образом (например, сделаем его массивом). Все значения T
будут изменены по отдельности. Например, возьмем следующий пример:
type User = {
age?: number,
name: string,
address?: string
}
type Horse = {
age?: number,
name: string
}
type UserType<T> = T extends { name: string } ? T[] : never;
// ^ -- will return the type arguement T as T[], if T contains the property `name` of type `string`
let myUser:UserType<User | Horse> = [{ name: "John" }, { name: "Horse" }]
// ^ -- becomes User[] | Horse[], since both User and Horse have the property name
Здесь мы упростили User
и Horse
, чтобы они имели только необходимое свойство name
. В нашем условном типе оба типа содержат свойство name
. Поэтому оба возвращают true, а возвращаемый тип — T[]
. Поскольку оба возвращают true, myUser
имеет тип User[] | Horse[]
, поэтому мы можем просто предоставить массив объектов, содержащих свойство name.
Обычно такое поведение нормально, но в некоторых случаях вы можете захотеть вернуть массив либо User
, либо Horse
. В этом случае, когда мы хотим избежать подобного распределения типов, мы можем добавить скобки вокруг T
и { name: string }
:
type User = {
age?: number,
name: string,
address?: string
}
type Horse = {
age?: number,
name: string
}
type UserType<T> = [T] extends [{ name: string }] ? T[] : never;
// ^ -- here, we avoid distributing the types, since T and { name: string } are in brackets
let myUser:UserType<User | Horse> = [{ name: "John" }, { name: "Horse" }]
// ^ -- that means the type is slightly different now - it is (User | Horse)[]
Благодаря использованию квадратных скобок наш тип теперь преобразован в (User | Horse)[]
, а не в User[] | Horse[]
. Это может быть полезно в некоторых специфических обстоятельствах, и это сложность условных типов, о которой полезно помнить.
Вывод типов с помощью условных типов
Мы также можем использовать ключевое слово infer
при использовании условных типов. Предположим, у нас есть два типа, один для массива чисел, а другой для массива строк. В этом простом случае infer
выведет типы каждого элемента массива и вернет правильный тип:
type StringArray = string[];
type NumberArray = number[];
type MixedArray = number[] | string[];
type ArrayType<T> = T extends Array<infer Item> ? Item : never;
let myItem1:ArrayType<NumberArray> = 45
// ^ -- since the items in `NumberArray` are of type `number`, the type of `myItem` is `number`.
let myItem2:ArrayType<StringArray> = 'string'
// ^ -- since the items in `StringArray` are of type `string`, the type of `myItem` is `string`.
let myItem3:ArrayType<MixedArray> = 'string'
// ^ -- since the items in `MixedArray` can be `string` or `number, the type of `myItem is `string | number`
Здесь мы определяем новый аргумент в нашем условном типе под названием Item
, который представляет собой элементы внутри Array
, который расширяет T
. Примечательно, что это работает только если тип, который мы передаем, является массивом, так как мы используем Array<infer Item>
.
В случае, если T
является массивом, то ArrayType
возвращает тип его элементов. Если T
не является массивом, то ArrayType
будет иметь тип never
.
Заключение
Условные типы в TypeScript поначалу могут показаться запутанными, но, по сути, это просто еще один способ упростить написание типов в некоторых конкретных обстоятельствах. Полезно знать, как это работает, если вы когда-нибудь увидите это в репозитории или проекте, или для упрощения своей собственной кодовой базы.
Надеюсь, вам понравилось это руководство. Если да, то вам также может понравиться статья, которую я написал о типе утилиты Record.