Ключевое слово infer: сопоставить тип с образцом и захватить его кусок
`infer` объявляет свежую типовую переменную в части extends условного типа, связанную с тем, что заполняет слот — движок за ReturnType, Awaited и кортежами. Одно имя infer в нескольких позициях объединяет ковариантных кандидатов, но пересекает контравариантных.
Вы пишете ReturnType<typeof fetchUser>, чтобы получить возвращаемый тип функции, которую не писали, ни разу его не называя. Где-то в lib.es5.d.ts есть одна строка: type ReturnType<T extends (...a: any) => any> = T extends (...a: any) => infer R ? R : any. Этот infer R — весь трюк: это дыра в образце, которая говорит «сопоставь тип-функцию, и что бы ни стояло в слоте возврата — захвати как R». Единственная встроенная форма сопоставления с образцом в TypeScript.
К концу урока вы напишете ReturnType, Awaited и кортежный head/tail по памяти — и поймёте, почему одно и то же имя infer в двух позициях иногда даёт объединение, а иногда never.
Дыра в образце
Когда нужно достать тип, живущий внутри чужого типа — возвращаемое значение функции, разрешённое значение промиса, первый элемент кортежа — можно оборачивать и разворачивать вручную. Или пробить дыру в образце и дать TypeScript самому найти решение.
infer допустим только внутри части extends условного типа. Он объявляет новую типовую переменную прямо на месте и позволяет TypeScript решить её, сопоставляя структуру:
type ElementOf<T> = T extends (infer E)[] ? E : never;
type A = ElementOf<string[]>; // ^? string
type B = ElementOf<[1, 2, 3]>; // ^? 1 | 2 | 3 (тип элемента кортежа)
type C = ElementOf<number>; // ^? never (не массив — ложная ветка)Вы написали форму (infer E)[] — «массив чего-то» — и TypeScript унифицирует её с входом, связывая E с этим «чем-то». E существует только в истинной ветке. Это та же механика, что использует стандартная библиотека:
type Unwrap<T> = T extends Promise<infer V> ? V : T;
type R1 = Unwrap<Promise<number>>; // ^? number
type R2 = Unwrap<string>; // ^? string (ложная ветка возвращает T)
type Params<T> = T extends (...args: infer P) => unknown ? P : never;
type R3 = Params<(a: string, b: number) => void>; // ^? [a: string, b: number]Голова, хвост кортежа и несколько infer в одном образце
Вы можете разместить несколько переменных infer в одном образце и захватить разные куски одновременно:
type Head<T extends readonly unknown[]> =
T extends [infer H, ...unknown[]] ? H : never;
type Tail<T extends readonly unknown[]> =
T extends [unknown, ...infer Rest] ? Rest : never;
type H = Head<[1, 2, 3]>; // ^? 1
type Te = Tail<[1, 2, 3]>; // ^? [2, 3]
type Last<T extends readonly unknown[]> =
T extends [...unknown[], infer L] ? L : never;
type L = Last<[1, 2, 3]>; // ^? 3Вариативные позиции кортежа (...infer Rest) делают голову/хвост/последний тривиальными — и они строительный блок для рекурсивных списков на уровне типов, которые вы напишете в юните 05.
Ограниченный infer (TS 4.7): infer R extends ...
До TS 4.7 приходилось захватывать, а затем перепроверять во вложенном условии. С 4.7 можно ограничить вывод прямо в строке, что одновременно валидирует и сужает захваченный тип:
// Захватываем строковый литерал-элемент и сужаем его до string за один шаг
type FirstString<T> =
T extends [infer S extends string, ...unknown[]] ? S : never;
type S1 = FirstString<["hello", 1, 2]>; // ^? "hello"
type S2 = FirstString<[1, "x"]>; // ^? never (первый — не строка)Практическая выгода: infer N extends number позволяет пронести числовой литерал без его расширения, что полезно в числовой арифметике типов.
Подвох с вариантностью: одно имя, несколько позиций
Вот часть, где ошибаются. Когда одно имя infer встречается в нескольких позициях, TypeScript собирает всех кандидатов и комбинирует их — но как зависит от вариантности. В ковариантных позициях (значения свойств объекта, элементы массива, возвращаемые типы) кандидаты объединяются:
type Co<T> = T extends { a: infer U; b: infer U } ? U : never;
type X = Co<{ a: string; b: number }>;
// ^? string | number — ковариантные позиции => ОБЪЕДИНЕНИЕВ контравариантных позициях — параметрах функций — кандидаты пересекаются:
type Contra<T> =
T extends { f: (x: infer U) => void; g: (x: infer U) => void } ? U : never;
type Y = Contra<{ f: (x: string) => void; g: (x: number) => void }>;
// ^? string & number — контравариантные позиции => ПЕРЕСЕЧЕНИЕ (здесь: never)▸Почему это работает
Правило вытекает из корректности. Захваченный U должен быть типом, который работает для каждой позиции, где он встретился. В ковариантной (выходной) позиции значение string | number приемлемо везде, где производилось любое из них — объединение является самым точным типом, покрывающим оба выхода. В контравариантной (входной) позиции функция, которая должна принять string, и функция, которая должна принять number, безопасно получают только значение, приемлемое для обеих — string & number. Поэтому проверщик объединяет выходы и пересекает входы; это та же логика вариантности из юнита 03, применённая к кандидатам вывода.
Цена: infer дёшев в написании, дорог в цепочке
Один infer почти бесплатен. Ловушка — рекурсивный infer: снятие кортежа по одной голове или разворачивание вложенных обёрток, потому что каждая рекурсия — это новое инстанцирование условия. TypeScript ограничивает нехвостовую рекурсию глубиной инстанцирования около 50, прежде чем сдаться с Type instantiation is excessively deep and possibly infinite; хвосторекурсивные формы доходят дальше, примерно до 1000 уровней. Обход infer по кортежу из 2000 элементов упрётся в эту стену, и симптом — не падение, а молчаливо подставленный на дне рекурсии any, который затем отравляет каждый зависящий от него нижестоящий тип.
Senior-критерий: infer уместен, когда нужный кусок действительно внутри типа, которым владеет кто-то другой — библиотечный ReturnType, Awaited над неизвестным промисом. Он неуместен, когда тип контролируете вы и кусок можно просто назвать. type UserId = User["id"] — это индексированный доступ, разрешается мгновенно, тривиально наводится — против type UserId = Extract<...infer...>, который читатель должен мысленно выполнить. А когда вы пишете 30-строчный рекурсивный infer, разбирающий строку или глубоко вложенную форму, это сигнал, что систему типов просят выполнить работу парсера: маленький шаг кодогенерации, выдающий плоский именованный тип, компилируется за микросекунды и даёт коллегам читаемый артефакт, тогда как рекурсивный infer стоит секунд проверки и порождает нечитаемое сообщение об ошибке.
▸lesson.inset.warning
Измеряйте, прежде чем доверять хитрому infer. Запустите tsc --extendedDiagnostics и следите за Instantiation count и Check time: невинный на вид рекурсивный infer способен загнать Instantiation count в миллионы и добавить 5–15с к холодной проверке на реальном проекте, а поскольку тот же checker питает редактор, IntelliSense на затронутом файле деградирует с мгновенного до многосекундного на нажатие. Один недоограниченный рекурсивный infer над большим объединением может также упереть вас в потолок объединения в 100 000 членов, всплывая как Expression produces a union type that is too complex to represent — и тогда захваченный тип вырождается в ошибку/any вместо точного типа, который вы задумали.
Для type Contra<T> = T extends { f: (x: infer U) => void; g: (x: infer U) => void } ? U : never чему равно Contra<{ f: (x: string) => void; g: (x: number) => void }>?
Упорядочьте, как TypeScript разрешает ReturnType<F> для F = (a: number) => string, при ReturnType<T> = T extends (...a: any) => infer R ? R : any:
- 1 Проверяет, совместим ли F с образцом-функцией (...a: any) => (что-то)
- 2 Унифицирует F с образцом, трактуя infer R как дыру в слоте возврата
- 3 Связывает R с сопоставленным возвращаемым типом, string
- 4 Берёт истинную ветку и возвращает R, поэтому ReturnType<F> — это string
- 01Где допустим `infer`, что он делает и как написать с ним ElementOf и ReturnType?
- 02Когда одно имя infer встречается в нескольких позициях, когда получается объединение, а когда пересечение, и почему?
- 03Что TS 4.7 добавил с ограниченным infer и почему это больше, чем синтаксический сахар?
infer превратил условие из проверки «да/нет» в инструмент захвата — теперь вы можете достать элемент из массива, значение из Promise, голову и хвост из кортежа, и вы знаете, что повтор имени infer объединяет выходы, но пересекает входы. Дальше — отображённые типы перестают сопоставлять одну форму с образцом и начинают её обходить: { [K in keyof T]: ... } проходит по каждому ключу типа, чтобы преобразовать его значения, с хирургией модификаторов (?, readonly) и гомоморфным свойством, которое незаметно сохраняет массивы и кортежи. Теперь, когда встречаете ReturnType, Awaited или любой тип, тянущийся внутрь чужой библиотеки, — вы узнаёте дыру infer и сразу понимаете, почему в одних позициях результат объединяется, а в других — пересекается.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.