Back

Понимание `infer` в TypeScript

Понимание `infer` в TypeScript

infer объявляет переменную типа внутри клаузулы extends условного типа, захватывая совпавший подтип для использования в ветке true — за пределами этой позиции ключевое слово не имеет смысла и вызывает ошибку компиляции. Если вы открывали файл .d.ts какой-либо библиотеки или PR коллеги и встречали что-то вроде T extends (...args: any[]) => infer R ? R : any, эта строка использует infer для извлечения части типа — так же, как деструктуризация извлекает значение из объекта.

Ключевые выводы

  • infer допустим только внутри клаузулы extends условного типа, а введённая им переменная находится в области видимости исключительно в ветке true — обращение к ней в ветке false является ошибкой компилятора.
  • infer был введён в TypeScript 2.8; шаблонный литерал с infer появился в 4.1, Awaited<T> — в 4.5, а infer X extends Constraint — в 4.7.
  • Когда параметр типа является «голой» переменной типа и вы передаёте объединение, условный тип распределяется по каждому его члену, поэтому infer вычисляется отдельно для каждого члена, а результаты собираются в объединение — это распространённая ошибка неявного расширения типа.
  • Начиная с TypeScript 4.5, Awaited<T> является встроенным средством для разворачивания цепочек Promise; нет никаких оснований вручную реализовывать UnwrapPromise в современном TypeScript.
  • Несколько кандидатов для одной и той же переменной infer дают объединение в ковариантных позициях и пересечение — в контравариантных.

Условные типы: субстрат, в котором живёт infer

Условный тип выбирает один из двух типов в зависимости от того, является ли один тип присваиваемым другому, используя форму T extends X ? A : B. Он работает как тернарный оператор, но на уровне типов: если T присваиваем X, тип разрешается в A, иначе — в B. Основным справочным материалом является раздел о условных типах в руководстве TypeScript.

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<42>;      // false

Поведение, которое сбивает людей с толку, — это дистрибутивность. Когда проверяемый тип является «голым» параметром типа — то есть T напрямую, без обёртки — и вы передаёте объединение, TypeScript распределяет условный тип по каждому члену объединения отдельно и собирает результаты в новое объединение. Оборачивание параметра в кортеж ([T] extends [X]) отключает дистрибутивность и проверяет объединение как единое целое. Это различие задокументировано в разделе о дистрибутивных условных типах.

type Distributed<T> = T extends string ? T[] : never;
type R1 = Distributed<string | number>; // string[] (член number разрешается в never и исключается)

type NonDistributed<T> = [T] extends [string] ? T[] : never;
type R2 = NonDistributed<string | number>; // never — проверяется как единое целое

Запомните это правило: оно лежит в основе наиболее распространённых неожиданностей при работе с infer, о которых рассказывается в разделе о подводных камнях ниже.

Анатомия паттерна с infer

Переменная типа, введённая с помощью infer, находится в области видимости только в ветке true условного типа; обращение к ней в ветке false является ошибкой компилятора TypeScript. infer вводит переменную внутри паттерна extends и привязывает её к тому, что совпадает с данной позицией при вычислении условного типа. Это делает infer похожим на деструктуризацию — но для типов: вы пишете паттерн, отражающий ожидаемую форму, называете нужные части и TypeScript их заполняет.

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

type A = ElementType<string[]>; // string
type B = ElementType<number>;   // number

Паттерн (infer U)[] означает «массив некоторого типа элементов — назовём этот тип U.» Когда T равно string[], U привязывается к string. Когда совпадение не происходит, выполняется ветка false, и U там уже недоступен:

type Bad<T> = T extends (infer U)[] ? U[] : U;
//                                          ^ Error: Cannot find name 'U'.

Один паттерн может содержать несколько переменных infer, каждая из которых привязывается к своей позиции:

type SplitFn<T> = T extends (arg: infer A) => infer R ? [A, R] : never;

type S = SplitFn<(x: number) => string>; // [number, string]

Здесь infer A захватывает тип параметра, а infer R — тип возвращаемого значения за один проход. Именно эту технику используют встроенные утилитарные типы.

infer в стандартной библиотеке: канонические экстракторы

Стандартная библиотека TypeScript реализует свои утилитарные типы-экстракторы с помощью infer. Каждый из них следует одной и той же форме: совпасть с паттерном, привязать нужную позицию, вернуть её в ветке true. Приведённые ниже определения взяты из lib/es5.d.ts.

Тип возвращаемого значения функции — ReturnType

ReturnType<T> извлекает тип возвращаемого значения функционального типа, сопоставляя форму функции и привязывая позицию возвращаемого значения к infer R. Определение из стандартной библиотеки ограничивает T вызываемым типом.

type ReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

type A = ReturnType<() => string>;        // string
type B = ReturnType<() => { id: number }>; // { id: number }

Обратите внимание на обобщённое ограничение T extends (...args: any) => any — оно является частью реального определения из стандартной библиотеки, а не опциональным украшением.

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

Параметры функции в виде кортежа — Parameters

Parameters<T> извлекает список параметров функции в виде кортежа, привязывая позицию rest-параметра к infer P.

type Parameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never;

type Args = Parameters<(a: string, b: number) => void>; // [a: string, b: number]

Вывод: Поскольку параметры моделируются как тип-кортеж, infer P над ...args даёт весь список, сохраняя метки и опциональность.

Разворачивание Promise — используйте Awaited<T>, а не самописный unwrapper

Начиная с TypeScript 4.5, встроенный Awaited<T> рекурсивно разворачивает цепочки Promise и обрабатывает PromiseLike — нет никаких оснований вручную реализовывать UnwrapPromise в современном TypeScript. Многие старые руководства определяют собственный T extends Promise<infer U> ? U : T, но эта одноуровневая версия не разворачивает вложенные промисы и не моделирует объекты с методом .then. Awaited делает и то, и другое, как описано в примечаниях к выпуску TypeScript 4.5.

type A = Awaited<Promise<string>>;            // string
type B = Awaited<Promise<Promise<number>>>;   // number — рекурсивно
type C = Awaited<boolean | Promise<number>>;  // number | boolean

Вывод: Используйте Awaited<T> напрямую. Пишите собственный unwrapper на основе infer только в учебных целях.

Тип элемента массива

Извлечение типа элемента массива — наименьший полезный паттерн с infer: совпасть с (infer U)[] и вернуть U.

type ElementOf<T> = T extends readonly (infer U)[] ? U : never;

type A = ElementOf<number[]>;          // number
type B = ElementOf<readonly string[]>; // string

Вывод: Включайте readonly в паттерн, чтобы утилита работала как для изменяемых, так и для readonly-массивов.

Голова, хвост и остаток кортежа

Кортежи поддерживают позиционные паттерны с infer и rest-элементами, позволяя извлекать первый элемент, хвост или последний элемент. Паттерны отражают деструктуризацию массивов в JavaScript.

type Head<T extends any[]> = T extends [infer H, ...any[]] ? H : never;
type Tail<T extends any[]> = T extends [any, ...infer Rest] ? Rest : never;
type Last<T extends any[]> = T extends [...any[], infer L] ? L : never;

type H = Head<[1, 2, 3]>; // 1
type R = Tail<[1, 2, 3]>; // [2, 3]
type L = Last<[1, 2, 3]>; // 3

Вывод: [infer Head, ...infer Rest] и [...any[], infer Last] — строительные блоки для рекурсивных манипуляций с кортежами; те же паттерны используются в типизированных роутерах и библиотеках для управления состоянием форм.

Рекурсивный вывод — Flatten

infer в сочетании с рекурсией позволяет типу разворачивать произвольно глубокие структуры. Flatten рекурсивно проходит через вложенные массивы до тех пор, пока не достигнет не-массивного типа.

type Flatten<T> = T extends Array<infer U> ? Flatten<U> : T;

type A = Flatten<number[][][]>; // number
type B = Flatten<string>;       // string

На каждом проходе U привязывается к типу элемента и передаётся обратно в Flatten. Когда T перестаёт быть массивом, ветка false возвращает его без изменений.

Вывод: Рекурсивный infer — это способ сворачивания вложенных контейнеров; та же форма разворачивает глубоко вложенные типы Promise или объектные типы.

Вывод из шаблонных литералов — разбор строк на уровне типов

Шаблонные литеральные типы поддерживают infer внутри своих подстановочных позиций. Приведённый ниже пример ExtractId<T> сопоставляет строковый паттерн и привязывает переменный сегмент к Id, обеспечивая типизированное извлечение параметров маршрута без парсера времени выполнения — паттерн, доступный начиная с TypeScript 4.1.

type ExtractId<T> = T extends `/users/${infer Id}` ? Id : never;

type A = ExtractId<"/users/42">;   // "42"
type B = ExtractId<"/posts/42">;   // never

Можно объединить несколько infer по нескольким сегментам, чтобы разобрать весь путь на части:

type RouteParams<T> =
  T extends `${infer _Start}/:${infer Param}/${infer Rest}`
    ? Param | RouteParams<`/${Rest}`>
    : T extends `${infer _Start}/:${infer Param}`
      ? Param
      : never;

type P = RouteParams<"/users/:userId/posts/:postId">; // "userId" | "postId"

Это эквивалент парсера путей на уровне типов, и именно он лежит в основе типизированной маршрутизации во фреймворках и библиотеках, которые выводят объекты параметров из строк маршрутов.

Вывод: infer в шаблонных литералах превращает строковые типы в структурированные данные без каких-либо затрат времени выполнения — полезно для параметров маршрутов, объединений CSS-свойств и разбора брендированных строковых форматов.

Ограниченный вывод: infer X extends Constraint (TS 4.7)

В TypeScript 4.7 добавлен синтаксис infer X extends Constraint, позволяющий условному типу ограничивать выводимую переменную непосредственно в момент сопоставления — объединяя вывод и проверку ограничения в одном шаге. Это задокументировано в примечаниях к выпуску TypeScript 4.7. До версии 4.7 требовалось сначала выполнить вывод, а затем добавить второй вложенный условный тип для сужения результата.

// До 4.7: сначала вывод, затем проверка во втором условном типе
type FirstStringOld<T> =
  T extends [infer H, ...any[]]
    ? H extends string ? H : never
    : never;

// 4.7+: ограничение в момент вывода
type FirstString<T> =
  T extends [infer H extends string, ...any[]] ? H : never;

type A = FirstString<["hi", 1, 2]>; // "hi"
type B = FirstString<[1, 2, 3]>;    // never

В TypeScript 4.8 был улучшен ограниченный вывод для примитивных типов, таких как number, bigint и boolean, что позволило компилятору сохранять более точные литеральные типы при сопоставлении с паттернами шаблонных строк. Это развивает синтаксис ограниченного infer, введённый в TypeScript 4.7, однако было реализовано как отдельное улучшение.

Вывод: Используйте infer X extends C, когда вам нужны одновременно захваченный тип и гарантия его формы в одном выражении — это заменяет старый паттерн «вывод, затем вложение».

Три подводных камня, которые незаметно ломают вывод

Три подводных камня, которые незаметно ломают infer: дистрибутивность по объединениям, расширяющая результаты; изменение направления вариантности для нескольких кандидатов infer между объединением и пересечением; а также слишком специфичные паттерны, проваливающиеся в ветку false. Ошибки infer обычно молчаливы — тип по-прежнему компилируется, но оказывается шире или уже, чем предполагалось.

1. Дистрибутивность незаметно расширяет результаты

Когда параметр типа T является «голой» переменной типа и вы передаёте объединение, TypeScript распределяет условный тип по каждому члену отдельно — это означает, что infer R вычисляется для каждого члена объединения, а результаты собираются в объединение, которое может незаметно расширить выводимый тип сверх ожидаемого.

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

// Выглядит как возврат одного типа; на самом деле распределяется
type R = Unwrap<Promise<string> | Promise<number>>; // string | number

Если вы хотите отклонять смешанные объединения, а не сворачивать их, оберните параметр для отключения дистрибутивности:

type UnwrapStrict<T> = [T] extends [Promise<infer U>] ? U : T;

Вывод: «Голый» T плюс объединение означает вычисление для каждого члена. Оборачивайте в [T], когда нужно обрабатывать объединение как единое целое.

2. Ковариантная и контравариантная позиции дают разные результаты

Несколько кандидатов для одной и той же переменной infer дают объединение в ковариантных позициях (например, типы возвращаемых значений) и пересечение — в контравариантных (например, типы параметров функций). Для перегруженных функций вывод использует последнюю сигнатуру. Это поведение описано в примечаниях к выпуску TypeScript 2.8.

// Ковариантная (позиция возвращаемого значения): объединение
type Co<T> = T extends { a: () => infer U; b: () => infer U } ? U : never;
type C = Co<{ a: () => string; b: () => number }>; // string | number

// Контравариантная (позиция параметра): пересечение
type Contra<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : never;
type D = Contra<{ a: (x: string) => void; b: (x: number) => void }>; // string & number

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

3. Паттерны, которые не могут привязаться, проваливаются в ветку false

infer привязывается только тогда, когда тип-кандидат структурно соответствует паттерну. Если форма не совпадает, условный тип переходит в ветку false, и выводимая переменная так и не разрешается — поэтому слишком специфичный паттерн молча возвращает запасной тип вместо ожидаемого значения.

type FirstArg<T> = T extends (a: infer A, b: infer B) => any ? A : never;

type X = FirstArg<string>; // never — string не соответствует функциональному типу

В этом примере FirstArg ожидает функциональный тип. Передача string не соответствует этому паттерну, поэтому условный тип разрешается в ветку false и возвращает never.

Вывод: Делайте паттерны настолько свободными, насколько позволяет сопоставление. Используйте ...args: infer P или [infer H, ...any[]] вместо форм с фиксированной арностью, если только вы специально не хотите отклонять другие арности.

Где infer встречается на практике

В прикладном коде вы редко пишете infer самостоятельно — вы сталкиваетесь с ним в определениях типов библиотек, таких как ComponentProps из React, RequestHandler из Express, PayloadAction из Redux Toolkit и типах эндпоинтов RTK Query, и используете получившиеся утилитарные типы. Умение распознавать паттерн T extends Wrapper<infer X> ? X : Fallback позволяет читать эти определения, а не воспринимать их как чёрные ящики.

ComponentProps из React. Типизации @types/react определяют ComponentProps с помощью infer для извлечения пропсов компонента из его типа, разветвляясь между элементами хоста и конструкторами компонентов. Читая его, вы увидите ту же форму T extends ... infer P ... ? P : ..., что и в ReturnType, применённую к типам элементов React.

Обработчики запросов Express. @types/express моделирует RequestHandler как обобщённый тип с параметрами для параметров маршрута, тела ответа, тела запроса и строки запроса. Библиотеки, выводящие типизированные обработчики маршрутов из строки пути, комбинируют эти обобщённые типы с infer в шаблонных литералах для извлечения сегментов :param — паттерн RouteParams, показанный ранее, является ядром этого механизма.

PayloadAction из Redux Toolkit. PayloadAction из Redux Toolkit — полезный тип для извлечения с помощью infer, хотя само его определение опирается на обобщённые типы и условные типы, а не на infer внутри — напоминание о том, что infer — это инструмент для чтения типов библиотеки, а не обязательно для её написания. Вы часто будете писать type Payload<A> = A extends PayloadAction<infer P> ? P : never для восстановления типа payload редьюсера из его экшена.

import type { PayloadAction } from "@reduxjs/toolkit";

type PayloadOf<A> = A extends PayloadAction<infer P> ? P : never;

type P = PayloadOf<PayloadAction<{ id: string }>>; // { id: string }

Эндпоинты RTK Query. RTK Query (часть Redux Toolkit) предоставляет сгенерированные типы эндпоинтов, из которых типы запросов и результатов можно извлечь с помощью утилит на основе infer. Извлечение типа результата эндпоинта с помощью условного типа, привязывающего позицию результата, — рутинная задача, как только вы освоите этот паттерн.

Заключение

infer — это одна возможность с одним правилом: он именует позицию внутри паттерна условного типа и привязывает к ней всё, что совпадает, — доступно только в ветке true. Как только вы начнёте воспринимать T extends Pattern<infer X> ? X : Fallback как деструктуризацию на уровне типов, каждый встроенный утилитарный тип и типизация библиотеки будут читаться как вариация этого единственного приёма. В следующий раз, когда вы встретите infer в файле .d.ts, проследите паттерн: найдите позицию, в которой находится переменная, проверьте, является ли параметр «голым» или обёрнутым, и убедитесь, что читаете ветку true. Начните с замены любого самописного UnwrapPromise в вашей кодовой базе на Awaited<T> и применяйте infer X extends Constraint там, где вы сейчас вкладываете второй условный тип для сужения результата.

Часто задаваемые вопросы

infer извлекает тип из существующего типа путём сопоставления с паттерном внутри условного типа, тогда как mapped type преобразует каждое свойство типа в новую форму. Используйте infer, когда нужно прочитать подтип из структуры, например тип возвращаемого значения функции или тип элемента массива. Используйте mapped type, когда нужно перебрать ключи и создать новый объектный тип. Они решают противоположные задачи: infer читает, mapped types переписывают.

Нет. Переменная типа, введённая с помощью infer, находится в области видимости только в ветке true условного типа; обращение к ней в ветке false вызывает ошибку компилятора TypeScript, например 'Cannot find name'. Переменная существует только после успешного сопоставления, поэтому в ветке false нет значения для привязки. Если вам нужно значение в обоих исходах, выполните вывод в ветке true и укажите не связанный с ним запасной тип в ветке false.

Из-за дистрибутивности. Когда параметр типа является «голой» переменной типа и вы передаёте объединение, TypeScript распределяет условный тип по каждому члену отдельно, поэтому infer вычисляется для каждого члена объединения, а результаты собираются в новое объединение. Это может незаметно расширить результат сверх ожидаемого. Чтобы отключить дистрибутивность и проверять объединение как единое целое, оберните параметр в кортеж, как в [T] extends [Promise<infer U>] ? U : T.

Нет, не в современном TypeScript. Начиная с TypeScript 4.5, встроенный Awaited<T> рекурсивно разворачивает цепочки Promise и обрабатывает thenable-объекты PromiseLike. Самописный T extends Promise<infer U> ? U : T разворачивает только один уровень и игнорирует thenable-объекты, поэтому ломается на вложенных промисах. Используйте Awaited<T> напрямую в прикладном коде, а собственный unwrapper на основе infer пишите только в учебных целях.

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay