infer: достать тип изнутри

условные типы≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Условный тип умеет ответить «да» или «нет». Но чаще нужен не ответ, а кусок: тип элемента внутри массива, тип значения внутри промиса, тип того, что вернёт функция. Ключевое слово infer объявляет переменную прямо в образце — «вот здесь что-то стоит, назови его U и отдай мне».

Разделы темы:·

Тип Promise<User> — это единое имя, а не объект, у которого можно взять поле. Достать User из него точкой или скобками не выйдет. Зато можно описать образец — «что-то похожее на промис от чего-то» — и попросить компилятор запомнить, что именно оказалось на месте этого «чего-то».

Синтаксис короткий: infer ставится справа от extends, ровно в том месте образца, содержимое которого нужно узнать. Имя после него — обычный параметр типа, и в ветке «да» им уже можно пользоваться.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика сама назвала результат: «Тип "number" не может быть назначен для типа "string"». Компилятор приложил string[] к образцу Array<infer U>, увидел на месте U тип string — и вернул его из ветки «да». Никакого перебора вариантов руками: ElementOf работает с любым массивом, хоть с Date[].

Что будет, если образец не подойдёт, решает ветка «нет». Обычно там пишут never — «такого типа не бывает».

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Тип ""хоть что-нибудь"" не может быть назначен для типа "never"» — присвоить значение типа never нельзя ничем, и это удобный сигнал: образец не сошёлся. Выполнение, как всегда, продолжилось: строка напечаталась, потому что типы до браузера не доезжают.

infer похож на скобочную группу в регулярном выражении: образец описывает форму, а группа помечает кусок, который нужно вернуть. Только сопоставляется не строка, а тип.

Именно для массива ничего изобретать не нужно: элемент достаётся индексным доступом, знакомым по главе про типы из значений.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Разница в том, что T[number] — частный приём для того, у чего есть индекс. У промиса, у функции, у класса никакого индекса нет: их внутренности видны только через образец с infer.

Асинхронная функция возвращает не данные, а обёртку вокруг них. Тип этой обёртки в коде встречается постоянно, а нужен обычно тот, что внутри. Образец пишется так же, только вместо Array — Promise.

Прежде чем запускать: Promise<Promise<string>> — что достанет отсюда наш Unwrap?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Тип "string" не может быть назначен для типа "Promise<string>"». Образец сработал ровно один раз и снял ровно один слой: на месте U оказался внутренний Promise<string>, им дело и кончилось. Условный тип не повторяется сам по себе — но его можно попросить.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обе строки дали одинаковую диагностику про string, и это не совпадение: встроенный Awaited из главы про утилитные типы устроен ровно так же — условный тип с infer, который вызывает сам себя. В настоящем Awaited ещё пара веток на null и на объекты с методом then, но ядро вы только что написали своими руками.

В настоящем коде источником типа обычно служит функция, а не выдуманный Promise<...>.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Добавится поле в loadNote — Note изменится сам. Ручная копия типа так не умеет: она расходится с оригиналом молча.

Тип функции — тоже форма: (параметры) => результат. Значит, infer ставится и туда, причём в оба места сразу.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Получился string — результат format, вытащенный из её типа. Странно выглядит только never[] на месте параметров. Смысл там такой: «параметры любые, они нас не интересуют». Проверим очевидное на вид написание — unknown[], «массив чего угодно».

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

never — образец не подошёл. Параметры при сравнении функций работают наоборот, чем всё остальное: подходит та функция, которая принимает не меньше, чем требует образец. (...args: unknown[]) требует функцию, готовую съесть что угодно, а format берёт только число — значит, не годится. С never[] требование пустое: принять «ничто» способна любая функция, и образец сходится всегда.

В стандартной библиотеке на этом месте стоит any — и в ограничении параметра, и в самом образце: type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;. Тот самый any, которого мы избегаем со статьи про две двери из системы типов: здесь он выключен намеренно и ровно в одном месте — на списке параметров, который образцу безразличен. В своём коде тот же результат даёт never[], и лазейки не появляется.

Параметры ловятся так же — но приезжают не по одному, а кортежем.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Кортеж из главы про массивы и кортежи, только собранный компилятором: [name: string, age: number]. Его можно хранить, передавать и разворачивать обратно в вызов через spread — а вторая строка со строкой вместо числа получает диагностику ровно на нужном месте.

Что достаёмОбразецГотовое в стандартной библиотеке
Элемент массиваT extends readonly (infer U)[] ? U : neverT[number]
Значение промисаT extends Promise<infer U> ? U : TAwaited<T>
Результат функцииF extends (...args: never[]) => infer R ? R : neverReturnType<F>
Параметры функцииF extends (...args: infer P) => unknown ? P : neverParameters<F>
Экземпляр классаC extends new (...args: never[]) => infer I ? I : neverInstanceType<C>

Готовое из правой колонки и стоит брать в работе. Левая нужна для случаев, которых в библиотеке нет, — а их в любом настоящем проекте хватает.

Соблазн написать infer где-нибудь ещё возникает быстро — например, просто объявить «переменную типа». Компилятор на это отвечает отдельным кодом ошибки.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«TS1338: объявления "infer" допустимы только в предложении "extends" условного типа». Это не придирка, а суть механизма: infer не объявляет тип, а ловит его при сопоставлении. Сопоставлять нечего — ловить нечего. Вторая диагностика, про присваивание, здесь лишь эхо первой: раз запись не разобрана, компилятор не знает, чем считать U.

Второе ограничение — область видимости. Пойманное имя живёт только в ветке «да».

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Не удается найти имя "U"» — и правильно: в ветку «нет» попадают как раз те типы, для которых сопоставление провалилось и U не получил значения. Ставьте туда never, исходный T или что-то осмысленное вроде запасного типа.

А вот полезная возможность, о которой узнают поздно: пойманному типу можно сразу поставить условие через extends. Не подошёл под него — сработает ветка «нет», как будто образец не сошёлся вовсе.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая строка молчит — I поймал литеральный тип 7. Вторая получила диагностику: string ограничение не прошёл, и IdOf вернул строку из ветки «нет». Так один образец и достаёт тип, и проверяет его.

Одно имя можно поставить в образце дважды. Тогда компилятор поймает оба типа и сведёт их в один — но по-разному, смотря где они стоят.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В обычных полях получается объединение: string | number — «либо то, либо это». А в параметрах функции, где сравнение идёт наоборот, получается пересечение.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

string и number одновременно не бывает, поэтому пересечение схлопнулось в never. Приём редкий, но встретив его в чужих типах, вы теперь знаете, почему один и тот же код даёт то объединение, то пустоту.

Первая ловушка ждёт там, где infer обычно и применяют впервые: на массиве из as const.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ветка «нет», never — хотя массив вот он. Дело в as const из статьи про неизменяемость: он делает не string[], а readonly ["админ", "автор", "гость"]. Изменяемый Array такому кортежу не родня, образец не сходится. Лечится одним словом.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Теперь образец принимает и изменяемые массивы, и readonly-кортежи, а U собрал из кортежа объединение литералов — диагностика перечисляет все три роли. Пишите в своих утилитах readonly (infer U)[] по умолчанию: этот вариант шире.

Вторая ловушка — распределение по объединению из прошлой статьи главы. Оно никуда не делось и работает вместе с infer.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Образец приложился к каждому варианту отдельно, и результаты собрались обратно: string | number. Обычно это то, что нужно. Но если требуется рассмотреть объединение целиком, придётся глушить распределение — тем самым приёмом с квадратными скобками, который разбирали в прошлой статье.

Третья ловушка серьёзнее и не имеет никакого отношения к синтаксису. Ниже компилятор промолчит — предскажите, что произойдёт на запуске.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики — и TypeError на исполнении. infer достаёт то, что написано в типах, а не то, что лежит в памяти. Соврали образцу через as — он честно вытащит вашу же неправду и передаст дальше по всем производным типам.

infer — инструмент вывода, а не проверки. Данные с границы программы всё так же нужно проверять кодом, который выполняется.

  • infer объявляет переменную прямо в образце условного типа и ловит в неё то, что стоит на этом месте.
  • Писать его можно только справа от extends, а пользоваться пойманным именем — только в ветке «да»; всё остальное компилятор отвергает кодом TS1338.
  • Один образец снимает ровно один слой: чтобы распаковать вложенность до конца, ветка «да» должна вызывать сам тип — так устроен встроенный Awaited.
  • Параметры функции сравниваются в обратную сторону, поэтому «любые аргументы» в образце — это never[], а не unknown[].
  • Для массивов пишите readonly (infer U)[]: иначе значения из as const провалятся в ветку «нет».
  • infer достаёт то, что написано в типах. Если тип получен через as и не совпадает с данными, ошибка доедет до запуска.

1/4Тип Unwrap<T> = T extends Promise<infer U> ? U : T применили к Promise<Promise<string>>. Что получится?

2/4Что скажет компилятор на строку type Element<T> = infer U;?

3/4ElementOf<T> = T extends Array<infer U> ? U : never применили к массиву, объявленному через as const. Что вернётся?

4/4Почему F extends (...args: unknown[]) => infer R для функции (price: number) => string даёт never?

Тип события из обработчика

Есть готовый тип обработчика. Напишите EventOf<F>, который достаёт из него тип единственного параметра, — тогда объект события можно будет собирать заранее, не переписывая его поля руками.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Образец — функция с одним параметром; infer ставится на его тип. Что вернёт функция, нам не важно, поэтому в позиции результата достаточно unknown.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Встроенный аналог — Parameters<Listener>[0]: он вернёт кортеж всех параметров, а мы возьмём из него первый. Свой вариант короче читается, когда параметр заведомо один.

Распаковать до дна

Flat снимает с массива один уровень вложенности, а нужно снять все: для number[][][] должен получиться number. Исправьте тип, не трогая остальные строки.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ветка «да» должна снова обратиться к самому типу — тогда распаковка повторится, пока образец сходится, а первый же не-массив уйдёт в ветку «нет».

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Это та же рекурсия, что в части I, только вычисляемая на этапе проверки типов. Условие выхода — ветка «нет»: как только очередной тип перестаёт быть массивом, образец не сходится и распаковка останавливается.

Что вернёт async-функция

Resolved должен давать тип, который получится после await. Сейчас он отдаёт промис целиком — поправьте образец.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Промис в образце можно раскрыть прямо на месте результата: infer разрешено ставить сколь угодно глубоко внутри формы.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тот же результат даёт связка готовых утилит — Awaited<ReturnType<typeof loadUser>>. Она надёжнее в бою: сработает и для обычной функции, которая промис не возвращает.