Условные типы и infer

Ещё один любимый вопрос на собеседованиях по TypeScript: «Как получить тип возвращаемого значения функции, не зная заранее, какая это функция?». Кандидат, который не знаком с условными типами, обычно теряется — а ведь именно так устроен встроенный тип ReturnType<T>, которым все пользуются, но мало кто понимает, как он написан.

Секрет в двух связанных механизмах: условные типы (conditional types) — это как тернарный оператор ? :, только для типов, — и оператор infer, который умеет «выхватывать» часть типа прямо посреди сравнения.

Условные типы: тернарник на уровне типов

В обычном JavaScript вы наверняка писали что-то вроде const result = isAdmin ? "полный доступ" : "обычный доступ";. Условный тип делает то же самое, но решение принимается не во время выполнения программы, а во время компиляции, и сравниваются не значения, а типы:

type IsString<T> = T extends string ? "да" : "нет";

type A = IsString<"привет">; // "да"
type B = IsString<number>;    // "нет"

Здесь extends в этом контексте — не наследование класса, а вопрос «можно ли считать T разновидностью string?». Если да — результат первый вариант после ?, если нет — второй, после :. Полностью по аналогии с обычным тернарным оператором, только всё происходит в мире типов, а не значений.

Оператор infer: достаём часть типа

Теперь самое интересное. Что если мы хотим не просто ответить «да/нет», а вытащить какую-то часть типа? Например, если перед нами функция, мы хотим узнать тип того, что она возвращает. Для этого существует infer — ключевое слово, которое можно поставить прямо внутри условия extends, и оно означает «выведи этот кусок типа и дай ему имя»:

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

Читается это так: «если T — функция, принимающая любые аргументы и возвращающая что-то, назови то, что она возвращает, буквой R, и верни R. Если T — вообще не функция, верни never (тип «такого не может быть»)».

Что выведет компилятор

Применим наш тип к реальной функции:

function getUser() {
  return { id: 1, name: "Аня" };
}

type UserFromFn = MyReturnType<typeof getUser>;

const u: UserFromFn = { id: 2, name: "Борис" };
console.log(u);

Вывод:

{ id: 2, name: 'Борис' }

Если навести курсор на UserFromFn в редакторе, компилятор покажет { id: number; name: string } — тип, который он сам вычислил, заглянув внутрь сигнатуры getUser. Заметьте: мы нигде не писали этот объектный тип руками — компилятор вывел его из тела функции через infer. А если попробовать присвоить u объект без поля name, будет ошибка типов — вывод действительно рабочий, а не «любой тип».

Как это работает под капотом

Когда компилятор видит T extends (...args: any[]) => infer R, он пытается «примерить» форму справа на реальный тип T. Если T действительно функция, TypeScript смотрит, что стоит у неё на месте возвращаемого типа, и подставляет это значение вместо R — как если бы вы решали уравнение и R было неизвестной. Если форма не подходит (например, T — это число, а не функция), условие уходит в ветку false, и R просто не вычисляется.

infer можно использовать не только с функциями. Частый пример — достать тип элемента массива:

type ElementType<T> = T extends (infer Item)[] ? Item : T;

type Num = ElementType<number[]>; // number
type Str = ElementType<string>;   // string (не массив — вернулся сам T)

Или «распаковать» промис — крайне частый вопрос, потому что именно так устроен встроенный Awaited<T>:

type Unwrap<T> = T extends Promise<infer Value> ? Value : T;

type Payload = Unwrap<Promise<{ status: number }>>;
// { status: number }

Скрытая ловушка: распределительность по union

Вот вопрос, который на собеседовании отделяет тех, кто зазубрил синтаксис, от тех, кто реально понимает систему типов. Что произойдёт, если подставить в условный тип объединение (union)?

type ToArray<T> = T extends any ? T[] : never;

type Result = ToArray<string | number>;

Интуиция подсказывает: «ну это просто (string | number)[]» — то есть один массив, где элементы либо строки, либо числа. Но компилятор выведет другое:

string[] | number[]

Это называется распределительным условным типом (distributive conditional type). Когда вы пишете условный тип вида T extends U ? X : Y, а на место «голого» параметра T подставляется union, компилятор автоматически «раздаёт» условие по каждому члену union отдельно, а потом объединяет результаты. То есть по сути он вычисляет ToArray<string> | ToArray<number>, что и даёт string[] | number[], а не один общий массив.

Эта особенность включается только тогда, когда параметр перед extends «голый» — то есть используется сам по себе, без обёртки. Если обернуть его в квадратные скобки с обеих сторон условия, распределение отключается, и вы получите тот самый один общий тип, который подсказывала интуиция:

type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

type Result2 = ToArrayNonDist<string | number>;
// (string | number)[]

Частые ошибки на собеседовании

Первая — путать extends в условных типах с обычным наследованием классов. Здесь это не «класс А наследует класс Б», а вопрос совместимости типов: «можно ли безопасно использовать значение типа T там, где ожидается значение типа U?».

Вторая — забывать, что infer можно использовать только внутри истинной ветки условия extends, а не где попало. Написать infer R в ветке после : нельзя — там R ещё не имеет смысла, потому что условие не выполнилось.

Третья, самая коварная — не замечать распределительность по union и удивляться, почему ToArray<string | number> вдруг «раздвоился» на два типа массива вместо одного. Именно этот пример чаще всего используют, чтобы отличить кандидата, который знает синтаксис, от того, кто понимает, как компилятор реально считает такие типы.

Итоги-шпаргалка

  • T extends U ? X : Y — тернарный оператор для типов: если T можно считать разновидностью U, результат X, иначе Y.
  • infer R ставится внутри условия extends и «выхватывает» часть типа, давая ей имя — так устроены ReturnType, Awaited и подобные встроенные утилиты.
  • Если параметр перед extends подставляется как «голый» union, условный тип автоматически распределяется по каждому члену union отдельно.
  • Обернуть параметр в [T] с обеих сторон условия — способ отключить распределительность и получить один общий тип.
Проверьте себя
1. Что делает infer внутри условного типа T extends (...args: any[]) => infer R ? R : never?
AПроверяет во время выполнения программы, вернула ли функция значение
BЗадаёт значение по умолчанию для возвращаемого типа, если функция ничего не возвращает
CИзвлекает тип возвращаемого значения функции и позволяет использовать его как R в результате
DЗаставляет TypeScript игнорировать реальный тип возврата и всегда подставлять any
2. Чему будет равен тип ToArray<string | number>, если ToArray<T> = T extends any ? T[] : never?
A(string | number)[]
Bstring[] | number[]
Cnever
Dany[]