open atlas
↑ К треку
Система типов TypeScript вглубь TS · 04 · 02

Ключевое слово infer: сопоставить тип с образцом и захватить его кусок

`infer` объявляет свежую типовую переменную в части extends условного типа, связанную с тем, что заполняет слот — движок за ReturnType, Awaited и кортежами. Одно имя infer в нескольких позициях объединяет ковариантных кандидатов, но пересекает контравариантных.

TS Middle ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

Вы пишете 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. 1 Проверяет, совместим ли F с образцом-функцией (...a: any) => (что-то)
  2. 2 Унифицирует F с образцом, трактуя infer R как дыру в слоте возврата
  3. 3 Связывает R с сопоставленным возвращаемым типом, string
  4. 4 Берёт истинную ветку и возвращает R, поэтому ReturnType<F> — это string
Вспомните перед уходом
  1. 01
    Где допустим `infer`, что он делает и как написать с ним ElementOf и ReturnType?
  2. 02
    Когда одно имя infer встречается в нескольких позициях, когда получается объединение, а когда пересечение, и почему?
  3. 03
    Что TS 4.7 добавил с ограниченным infer и почему это больше, чем синтаксический сахар?
Итог

infer превратил условие из проверки «да/нет» в инструмент захвата — теперь вы можете достать элемент из массива, значение из Promise, голову и хвост из кортежа, и вы знаете, что повтор имени infer объединяет выходы, но пересекает входы. Дальше — отображённые типы перестают сопоставлять одну форму с образцом и начинают её обходить: { [K in keyof T]: ... } проходит по каждому ключу типа, чтобы преобразовать его значения, с хирургией модификаторов (?, readonly) и гомоморфным свойством, которое незаметно сохраняет массивы и кортежи. Теперь, когда встречаете ReturnType, Awaited или любой тип, тянущийся внутрь чужой библиотеки, — вы узнаёте дыру infer и сразу понимаете, почему в одних позициях результат объединяется, а в других — пересекается.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 8 завершено
Связанные уроки
углубляется в

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.