infer: достать тип изнутри
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Условный тип умеет ответить «да» или «нет». Но чаще нужен не ответ, а кусок: тип элемента внутри массива, тип значения внутри промиса, тип того, что вернёт функция. Ключевое слово infer объявляет переменную прямо в образце — «вот здесь что-то стоит, назови его U и отдай мне».
Тип Promise<User> — это единое имя, а не объект, у которого можно взять поле. Достать User из него точкой или скобками не выйдет. Зато можно описать образец — «что-то похожее на промис от чего-то» — и попросить компилятор запомнить, что именно оказалось на месте этого «чего-то».
Синтаксис короткий: infer ставится справа от extends, ровно в том месте образца, содержимое которого нужно узнать. Имя после него — обычный параметр типа, и в ветке «да» им уже можно пользоваться.
Диагностика сама назвала результат: «Тип "number" не может быть назначен для типа "string"». Компилятор приложил string[] к образцу Array<infer U>, увидел на месте U тип string — и вернул его из ветки «да». Никакого перебора вариантов руками: ElementOf работает с любым массивом, хоть с Date[].
Что будет, если образец не подойдёт, решает ветка «нет». Обычно там пишут never — «такого типа не бывает».
«Тип ""хоть что-нибудь"" не может быть назначен для типа "never"» — присвоить значение типа never нельзя ничем, и это удобный сигнал: образец не сошёлся. Выполнение, как всегда, продолжилось: строка напечаталась, потому что типы до браузера не доезжают.
infer похож на скобочную группу в регулярном выражении: образец описывает форму, а группа помечает кусок, который нужно вернуть. Только сопоставляется не строка, а тип.
Именно для массива ничего изобретать не нужно: элемент достаётся индексным доступом, знакомым по главе про типы из значений.
Разница в том, что T[number] — частный приём для того, у чего есть индекс. У промиса, у функции, у класса никакого индекса нет: их внутренности видны только через образец с infer.
Асинхронная функция возвращает не данные, а обёртку вокруг них. Тип этой обёртки в коде встречается постоянно, а нужен обычно тот, что внутри. Образец пишется так же, только вместо Array — Promise.
Прежде чем запускать: Promise<Promise<string>> — что достанет отсюда наш Unwrap?
«Тип "string" не может быть назначен для типа "Promise<string>"». Образец сработал ровно один раз и снял ровно один слой: на месте U оказался внутренний Promise<string>, им дело и кончилось. Условный тип не повторяется сам по себе — но его можно попросить.
Обе строки дали одинаковую диагностику про string, и это не совпадение: встроенный Awaited из главы про утилитные типы устроен ровно так же — условный тип с infer, который вызывает сам себя. В настоящем Awaited ещё пара веток на null и на объекты с методом then, но ядро вы только что написали своими руками.
В настоящем коде источником типа обычно служит функция, а не выдуманный Promise<...>.
Добавится поле в loadNote — Note изменится сам. Ручная копия типа так не умеет: она расходится с оригиналом молча.
Тип функции — тоже форма: (параметры) => результат. Значит, infer ставится и туда, причём в оба места сразу.
Получился string — результат format, вытащенный из её типа. Странно выглядит только never[] на месте параметров. Смысл там такой: «параметры любые, они нас не интересуют». Проверим очевидное на вид написание — unknown[], «массив чего угодно».
never — образец не подошёл. Параметры при сравнении функций работают наоборот, чем всё остальное: подходит та функция, которая принимает не меньше, чем требует образец. (...args: unknown[]) требует функцию, готовую съесть что угодно, а format берёт только число — значит, не годится. С never[] требование пустое: принять «ничто» способна любая функция, и образец сходится всегда.
В стандартной библиотеке на этом месте стоит any — и в ограничении параметра, и в самом образце: type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;. Тот самый any, которого мы избегаем со статьи про две двери из системы типов: здесь он выключен намеренно и ровно в одном месте — на списке параметров, который образцу безразличен. В своём коде тот же результат даёт never[], и лазейки не появляется.
Параметры ловятся так же — но приезжают не по одному, а кортежем.
Кортеж из главы про массивы и кортежи, только собранный компилятором: [name: string, age: number]. Его можно хранить, передавать и разворачивать обратно в вызов через spread — а вторая строка со строкой вместо числа получает диагностику ровно на нужном месте.
| Что достаём | Образец | Готовое в стандартной библиотеке |
|---|---|---|
| Элемент массива | T extends readonly (infer U)[] ? U : never | T[number] |
| Значение промиса | T extends Promise<infer U> ? U : T | Awaited<T> |
| Результат функции | F extends (...args: never[]) => infer R ? R : never | ReturnType<F> |
| Параметры функции | F extends (...args: infer P) => unknown ? P : never | Parameters<F> |
| Экземпляр класса | C extends new (...args: never[]) => infer I ? I : never | InstanceType<C> |
Готовое из правой колонки и стоит брать в работе. Левая нужна для случаев, которых в библиотеке нет, — а их в любом настоящем проекте хватает.
Соблазн написать infer где-нибудь ещё возникает быстро — например, просто объявить «переменную типа». Компилятор на это отвечает отдельным кодом ошибки.
«TS1338: объявления "infer" допустимы только в предложении "extends" условного типа». Это не придирка, а суть механизма: infer не объявляет тип, а ловит его при сопоставлении. Сопоставлять нечего — ловить нечего. Вторая диагностика, про присваивание, здесь лишь эхо первой: раз запись не разобрана, компилятор не знает, чем считать U.
Второе ограничение — область видимости. Пойманное имя живёт только в ветке «да».
«Не удается найти имя "U"» — и правильно: в ветку «нет» попадают как раз те типы, для которых сопоставление провалилось и U не получил значения. Ставьте туда never, исходный T или что-то осмысленное вроде запасного типа.
А вот полезная возможность, о которой узнают поздно: пойманному типу можно сразу поставить условие через extends. Не подошёл под него — сработает ветка «нет», как будто образец не сошёлся вовсе.
Первая строка молчит — I поймал литеральный тип 7. Вторая получила диагностику: string ограничение не прошёл, и IdOf вернул строку из ветки «нет». Так один образец и достаёт тип, и проверяет его.
Одно имя можно поставить в образце дважды. Тогда компилятор поймает оба типа и сведёт их в один — но по-разному, смотря где они стоят.
В обычных полях получается объединение: string | number — «либо то, либо это». А в параметрах функции, где сравнение идёт наоборот, получается пересечение.
string и number одновременно не бывает, поэтому пересечение схлопнулось в never. Приём редкий, но встретив его в чужих типах, вы теперь знаете, почему один и тот же код даёт то объединение, то пустоту.
Первая ловушка ждёт там, где infer обычно и применяют впервые: на массиве из as const.
Ветка «нет», never — хотя массив вот он. Дело в as const из статьи про неизменяемость: он делает не string[], а readonly ["админ", "автор", "гость"]. Изменяемый Array такому кортежу не родня, образец не сходится. Лечится одним словом.
Теперь образец принимает и изменяемые массивы, и readonly-кортежи, а U собрал из кортежа объединение литералов — диагностика перечисляет все три роли. Пишите в своих утилитах readonly (infer U)[] по умолчанию: этот вариант шире.
Вторая ловушка — распределение по объединению из прошлой статьи главы. Оно никуда не делось и работает вместе с infer.
Образец приложился к каждому варианту отдельно, и результаты собрались обратно: string | number. Обычно это то, что нужно. Но если требуется рассмотреть объединение целиком, придётся глушить распределение — тем самым приёмом с квадратными скобками, который разбирали в прошлой статье.
Третья ловушка серьёзнее и не имеет никакого отношения к синтаксису. Ниже компилятор промолчит — предскажите, что произойдёт на запуске.
Ни одной диагностики — и 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>, который достаёт из него тип единственного параметра, — тогда объект события можно будет собирать заранее, не переписывая его поля руками.
Образец — функция с одним параметром; infer ставится на его тип. Что вернёт функция, нам не важно, поэтому в позиции результата достаточно unknown.
Встроенный аналог — Parameters<Listener>[0]: он вернёт кортеж всех параметров, а мы возьмём из него первый. Свой вариант короче читается, когда параметр заведомо один.
Распаковать до дна
Flat снимает с массива один уровень вложенности, а нужно снять все: для number[][][] должен получиться number. Исправьте тип, не трогая остальные строки.
Ветка «да» должна снова обратиться к самому типу — тогда распаковка повторится, пока образец сходится, а первый же не-массив уйдёт в ветку «нет».
Это та же рекурсия, что в части I, только вычисляемая на этапе проверки типов. Условие выхода — ветка «нет»: как только очередной тип перестаёт быть массивом, образец не сходится и распаковка останавливается.
Что вернёт async-функция
Resolved должен давать тип, который получится после await. Сейчас он отдаёт промис целиком — поправьте образец.
Промис в образце можно раскрыть прямо на месте результата: infer разрешено ставить сколь угодно глубоко внутри формы.
Тот же результат даёт связка готовых утилит — Awaited<ReturnType<typeof loadUser>>. Она надёжнее в бою: сработает и для обычной функции, которая промис не возвращает.