Условные типы в TypeScript
Условный тип — это тип, который зависит от другого типа. Он работает как конструкция if-else, но для типов, а не для значений.
Как понимать условные типы в TypeScript
Условные типы позволяют строить типы, которые меняются в зависимости от переданного типа. Благодаря им становятся возможными сложные преобразования типов и программирование на уровне типов.
Ключевые понятия:
- Логика на уровне типов: проверка условия для типа
- Вывод типов: разбор типа на части с помощью
infer - Композиция: сочетание с другими возможностями TypeScript
- Вспомогательные типы: создание переиспользуемых помощников для типов
Частые случаи применения:
- Типобезопасная перегрузка функций
- Преобразование типов ответов API
- Сложная проверка типов
- Переиспользуемые утилиты для типов
- Продвинутый вывод типов
Базовый синтаксис условного типа
Условный тип имеет вид T extends U ? X : Y. Читается это так: если тип T расширяет (то есть совместим по присваиванию с) тип U, используется тип X, иначе — тип Y.
Пример
IsString<T> становится true для string и для литерала "hello", потому что литерал расширяет свой базовый тип. Для number получается false. Полученный тип можно использовать и для переменных.
T extends U ? X : Y.
Объявите IsString<T> как T extends string ? true : false, затем псевдонимы Result1, Result2 и Result3 для string, number и литерала "hello" с комментариями, указывающими true, false, true, и две переменные a: IsString<string> и b: IsString<number> с комментариями, поясняющими их типы 'true' и 'false'. Только типы, ничего не выводится.
Вывод: программа на TypeScript и захваченный вывод
type IsString<T> = T extends string ? true : false;
// Примеры использования
type Result1 = IsString<string>; // true
type Result2 = IsString<number>; // false
type Result3 = IsString<"hello">; // true (литеральные типы расширяют свои базовые типы)
// Это можно использовать и с переменными
let a: IsString<string>; // a имеет тип 'true'
let b: IsString<number>; // b имеет тип 'false'
Условные типы с объединениями
Условные типы особенно полезны с типами-объединениями. Когда проверяемый тип является объединением, условие применяется к каждому его члену по отдельности. Это называется дистрибутивным условным типом.
Дистрибутивные условные типы
ToArray<string | number> разбивается на ToArray<string> | ToArray<number>, что даёт string[] | number[]. Та же дистрибутивность позволяет типу ExtractString оставить только строковые члены объединения, отображая остальные в never.
ToArray<T> как T extends any ? T[] : never и StringOrNumberArray = ToArray<string | number> с комментариями, объясняющими, что получается string[] | number[], затем ExtractString<T>, который оставляет только строковые члены, и StringsOnly, применённый к string | number | boolean | "hello", с комментарием, указывающим string | "hello". Только типы, ничего не выводится.
Вывод: программа на TypeScript и захваченный вывод
type ToArray<T> = T extends any ? T[] : never;
// При использовании с типом-объединением применяется к каждому члену объединения
type StringOrNumberArray = ToArray<string | number>;
// Это превращается в ToArray<string> | ToArray<number>
// Что даёт string[] | number[]
// Из объединения можно также извлечь определённые типы
type ExtractString<T> = T extends string ? T : never;
type StringsOnly = ExtractString<string | number | boolean | "hello">;
// Результат: string | "hello"
Вывод типов с помощью infer
Ключевое слово infer объявляет типовую переменную внутри условия условного типа. Если условие совпадает, переменная хранит захваченный тип, и его можно использовать в ветке «истина».
Извлечение типов из сложных структур
ReturnType захватывает возвращаемый тип R типа функции. ElementType захватывает тип элемента U типа массива. С помощью typeof greet в эти помощники можно передать тип реальной функции.
infer захватывает возвращаемый тип и тип элемента массива.
Объявите ReturnType<T> как T extends (...args: any[]) => infer R ? R : never, две функции greet(), возвращающую "Hello, world!", и getNumber(), возвращающую 42, а также псевдонимы GreetReturnType/NumberReturnType с использованием typeof и комментариями, указывающими string и number. Затем объявите ElementType<T>, который выводит U из (infer U)[], и псевдонимы для number[] и string[]. Только типы, ничего не выводится; сделайте файл модулем, чтобы локальный ReturnType не конфликтовал с глобальным.
Вывод: программа на TypeScript и захваченный вывод
// Извлекаем возвращаемый тип типа функции
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// Примеры
function greet() { return "Hello, world!"; }
function getNumber() { return 42; }
type GreetReturnType = ReturnType<typeof greet>; // string
type NumberReturnType = ReturnType<typeof getNumber>; // number
// Извлекаем тип элемента из массива
type ElementType<T> = T extends (infer U)[] ? U : never;
type NumberArrayElement = ElementType<number[]>; // number
type StringArrayElement = ElementType<string[]>; // string
// Делаем этот файл модулем, чтобы локальный ReturnType не конфликтовал со встроенным глобальным
export {};
Встроенные условные типы
Стандартная библиотека TypeScript уже содержит несколько условных типов, поэтому самые распространённые из них писать самостоятельно не нужно.
Утилиты стандартной библиотеки
Extract оставляет подходящие члены объединения, Exclude их удаляет, NonNullable убирает null и undefined, а Parameters и ReturnType разбирают тип функции на части.
OnlyStrings = Extract<string | number | boolean, string>, NoStrings = Exclude<string | number | boolean, string>, NotNull = NonNullable<string | null | undefined>, Params = Parameters<(a: string, b: number) => void> и Return = ReturnType<() => string>, каждый с комментарием, объясняющим помощник и получившийся тип. Только типы, ничего не выводится.
Вывод: программа на TypeScript и захваченный вывод
// Extract<T, U> - извлекает из T типы, совместимые по присваиванию с U
type OnlyStrings = Extract<string | number | boolean, string>; // string
// Exclude<T, U> - исключает из T типы, совместимые по присваиванию с U
type NoStrings = Exclude<string | number | boolean, string>; // number | boolean
// NonNullable<T> - убирает из T значения null и undefined
type NotNull = NonNullable<string | null | undefined>; // string
// Parameters<T> - извлекает типы параметров из типа функции
type Params = Parameters<(a: string, b: number) => void>; // [string, number]
// ReturnType<T> - извлекает возвращаемый тип из типа функции
type Return = ReturnType<() => string>; // string
Продвинутые приёмы и шаблоны
Условные типы могут вызывать сами себя, выстраиваться в цепочку наподобие лестницы if-else и описывать возвращаемый тип обобщённой функции.
Рекурсивные условные типы
Условный тип может ссылаться на самого себя. UnwrapPromise продолжает разворачивать тип, пока тот не перестанет быть Promise, поэтому даже промис внутри промиса сводится к обычному внутреннему типу.
Promise.
Объявите UnwrapPromise<T> как T extends Promise<infer U> ? UnwrapPromise<U> : T, затем псевдонимы A, B и C для Promise<string>, Promise<Promise<number>> и boolean с комментариями, указывающими string, number и boolean. Только типы, ничего не выводится.
Вывод: программа на TypeScript и захваченный вывод
// Полностью разворачиваем типы Promise
type UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T;
// Примеры
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<Promise<Promise<number>>>; // number
type C = UnwrapPromise<boolean>; // boolean
Цепочки if-else на уровне типов
Несколько условий можно объединить в цепочку, одно за другим. TypeName по очереди сверяет тип с string, number, boolean, undefined и Function, а в остальных случаях возвращает "object".
TypeName<T>, который отображает string, number, boolean, undefined и Function в строковые литералы "string", "number", "boolean", "undefined" и "function", а в остальных случаях возвращает "object". Добавьте псевдонимы от T0 до T4 для string, 42, true, () => void и Date[] с комментариями, показывающими результаты. Только типы, ничего не выводится.
Вывод: программа на TypeScript и захваченный вывод
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
// Использование
type T0 = TypeName<string>; // "string"
type T1 = TypeName<42>; // "number"
type T2 = TypeName<true>; // "boolean"
type T3 = TypeName<() => void>; // "function"
type T4 = TypeName<Date[]>; // "object"
Условные возвращаемые типы
Условные типы особенно хороши в обобщённых утилитах и типобезопасных библиотеках. Здесь возвращаемый тип обобщённой функции зависит от типа её аргумента. Внутри функции нужно приведение as any, потому что TypeScript не может проверить тело функции по условному возвращаемому типу.
processValue<T>(value: T), возвращающую string для строк, number для чисел, boolean для логических значений и never в остальных случаях; переводите строки в верхний регистр, удваивайте числа, инвертируйте логические значения (каждый раз с приведением as any) и выбрасывайте Unsupported type для всего остального. Вызовите её с "hello", 10 и true, сохранив результаты в stringResult, numberResult и boolResult с комментариями, указывающими "HELLO", 20 и false. Ничего не выводится.
Вывод: программа на TypeScript и захваченный вывод
// Функция, которая возвращает разные типы в зависимости от типа аргумента
function processValue<T>(value: T): T extends string
? string
: T extends number
? number
: T extends boolean
? boolean
: never {
if (typeof value === "string") {
return value.toUpperCase() as any; // Приведение типа нужно из-за ограничений
} else if (typeof value === "number") {
return (value * 2) as any;
} else if (typeof value === "boolean") {
return (!value) as any;
} else {
throw new Error("Неподдерживаемый тип");
}
}
// Использование
const stringResult = processValue("hello"); // Возвращает "HELLO" (тип - string)
const numberResult = processValue(10); // Возвращает 20 (тип - number)
const boolResult = processValue(true); // Возвращает false (тип - boolean)
Рекомендации
Делайте так:
- Используйте условные типы для сложных преобразований типов
- Сочетайте их с
infer, чтобы извлекать части типа - Создавайте переиспользуемые утилиты для типов
- Документируйте сложные условные типы
- Проверяйте граничные случаи ваших определений типов
Не делайте так:
- Не беритесь за сложные условные типы, когда достаточно простого типа
- Не стройте глубоко вложенные условные типы, которые трудно читать
- Не забывайте, что очень сложные типы замедляют компилятор
- Не используйте условные типы для логики времени выполнения — они существуют только на этапе компиляции
Соображения производительности:
- Глубоко вложенные условные типы увеличивают время компиляции
- Сохраняйте промежуточные результаты в псевдонимах типов
- Помните об ограничениях TypeScript на глубину рекурсии