Как TypeScript выводит типы переменных: разбор алгоритма
Почему TypeScript иногда выводит unknown вместо конкретного типа? Разбираем двухфазный алгоритм инференса type variables: сбор кандидатов, ковариантность, контравариантность, приоритеты и странное поведение интерсекций.
Если вы когда-нибудь ловили себя на мысли, что TypeScript ведёт себя странно с дженериками, знайте: вы не одиноки. TypeScript выводит типовые переменные в две фазы: сначала собирает кандидатов из аргументов и контекста, затем сворачивает список в единственный тип. Иногда компилятор выводит unknown там, где ожидаешь конкретный тип, а иногда — наоборот, слишком поспешно захватывает весь объект вместо его «остаточной» части. В этой статье разберём, как внутри устроен алгоритм вывода типовых переменных — и почему он ведёт себя именно так.
Это не дословный перевод, а авторский разбор на основе исследования Nicolas Laurent (norswap), дополненный практическими примерами для enterprise-разработки на TypeScript.
Что такое вывод типовых переменных
В TypeScript, когда вы вызываете функцию с дженериком function foo<T>(x: T), компилятор должен понять, чему равен T. Этот процесс называется type variable inference — выводом типовой переменной. Он происходит в две фазы: сначала TypeScript собирает «кандидатов» из типов аргументов и контекста возврата, а затем «сворачивает» список кандидатов в единственный тип.
Звучит просто, но на практике алгоритм полон тонкостей: covariant и contravariant позиции, приоритеты кандидатов, странное поведение пересечений и неочевидные ограничения. Разберём всё по порядку.
Ключевые выводы
TypeScript выводит типовые переменные в две фазы: сбор кандидатов из аргументов и контекста, затем свёртка списка в единственный тип.
Кандидаты делятся на ковариантные (выходные позиции) и контравариантные (входные позиции); контравариантный результат обычно побеждает.
При свёртке TypeScript ищет общий супертип в списке кандидатов, но никогда не выбирает тип, которого нет в списке.
Пересечения типов ведут себя непредсказуемо: иногда TypeScript «снимает» литеральный тип, иногда захватывает весь объект.
В generic-контексте условные типы (extends ? :) не вычисляются, что ломает инференс через Exclude и подобные утилиты.
NoInfer<T> (с TypeScript 5.4+) позволяет блокировать вывод типа из конкретной позиции — полезно для API со значениями по умолчанию.
Фаза 1: сбор кандидатов
Когда TypeScript видит вызов функции с дженериком, он сопоставляет типы аргументов (source types) с типами параметров функции (target types). Каждый раз, когда «прогулка» по типам доходит до «голого» type parameter в target, соответствующий source type записывается как кандидат.
Ковариантные и контравариантные кандидаты
Кандидаты собираются в два списка. Если type parameter находится в позиции аргумента функции или возвращаемого значения — это ковариантный (output position) кандидат. Если type parameter находится в позиции параметра callback-функции — это контравариантный (input position) кандидат.
Условные типы и infer
Когда TypeScript встречает условный тип, кандидаты собираются только из той ветки, которая теоретически может сработать. Но важно: само условие не добавляет кандидатов. Запись T extends Foo НЕ добавляет Foo как кандидат для T.
Это классическая ловушка: кандидаты собираются из одной ветки, а типовая проверка — из другой. TypeScript не вычисляет условные типы на этапе инференса.
Распределение по union
Если source type — union, а target — не «голый» type parameter, TypeScript проходит по каждой ветке union отдельно и собирает кандидатов из всех веток в один список.
Что НЕ учитывается при сборе
- Условие условного типа (только ветки).
- Constraints (
<T extends Foo>) — они проверяются позже, на этапе разрешения. - Дефолтные значения type parameter (
<T = unknown>) — используются только если список кандидатов пуст. - Супертипы source type.
Фаза 2: разрешение кандидатов
После сбора TypeScript сворачивает каждый список в единственный тип. Алгоритм зависит от вариантности и содержимого списка.
Ковариантные кандидаты
- Если все кандидаты — литералы одного базового типа, они объединяются в union.
- Иначе ищется кандидат, который является строгим супертипом всех остальных. Если найден — он выбирается.
- Если нет — выполняется left-reduce: начинаем с первого кандидата, идём по списку, заменяя текущий на супертип, если встречаем его.
Важно:
Если в списке кандидатов нет общего супертипа, TypeScript выбирает первый элемент. Это частая причина неожиданных ошибок при передаче разнородных аргументов одного дженерика.
Контравариантные кандидаты
Для контравариантных кандидатов логика зеркальная: ищется общий подтип, иначе — left-reduce с подтипами. Если оба списка (ковариантный и контравариантный) непусты, контравариантный результат обычно побеждает — за исключением случаев, когда ковариантный результат является подтипом контравариантного.
Приоритеты кандидатов
Каждый кандидат помечается приоритетом. Кандидат с более высоким приоритетом стирает все кандидаты с более низким приоритетом. Основные приоритеты: None (0), NakedTypeVariable (1), MappedTypeConstraint (32), ReturnType (128), LiteralKeyof (256).
Приоритеты MappedTypeConstraint, ReturnType и LiteralKeyof — «комбинационные»: при них результатом становится union или intersection всех кандидатов, а не единственный тип.
Пересечения: загадочное поведение
Одно из самых неинтуитивных мест — вывод через пересечения. Рассмотрим несколько примеров.
Почему так? Это эвристики компилятора, направленные на максимизацию полезности захваченного типа. В первом случае полезнее получить весь объект, во втором — сохранить литеральную точность строки. Порядок операндов пересечения не влияет на результат.
Вывод в generic-контексте
Когда инференс происходит внутри другой generic-функции, условные типы не могут быть вычислены, потому что type variables ещё не связаны. Это ломает паттерны вроде Exclude, если вызывать их через generic-обёртку.
В типовых enterprise-проектах, где активно используются типовые утилиты для валидации API (например, в проектах на NestJS или с Zod), это ограничение часто приводит к необходимости явно прописывать generic-аргументы или реструктурировать типы.
NoInfer: как управлять выводом
Начиная с TypeScript 5.4, в языке есть встроенный вспомогательный тип NoInfer<T>. Он блокирует сбор кандидатов для T из той позиции, где используется NoInfer<T>.
Это особенно полезно в библиотечном коде, где нужно разделить «источник истины» для вывода типа и позиции, которые должны ему подчиняться.
FAQ
Часто задаваемые вопросы
Почему TypeScript иногда выбирает Dog вместо Animal, хотя оба аргумента подходят?
При разрешении ковариантных кандидатов TypeScript ищет тип из списка, который является супертипом всех остальных. Если такого нет — остаётся первый кандидат. Animal не попадает в список кандидатов автоматически: кандидаты собираются только из структурных частей переданных аргументов, а не из их супертипов.
Может ли TypeScript вывести string из аргумента типа number?
Нет. TypeScript собирает кандидатов только из source type и его структурных компонентов. Если вы передали number, кандидатом может быть только number или его части (например, литерал 42), но не string. Это фундаментальное ограничение алгоритма.
Почему Exclude не работает внутри generic-функции?
Потому что условные типы не вычисляются, когда type variable ещё не связан. Внутри function bar<X>(x: Exclude<X, number>) условие X extends number остаётся невычисленным. Чтобы тип прошёл проверку, нужно либо явно указать X при вызове, либо гарантировать структурную идентичность типов.
Что делать, если интерсекция ведёт себя непредсказуемо?
Если TypeScript захватывает слишком много в type parameter, попробуйте изменить структуру типа: вынесите «фиксированную» часть в отдельный параметр или используйте NoInfer для блокировки вывода. В крайнем случае — явно указывайте generic-аргументы при вызове.
Как NoInfer помогает в реальных проектах?
NoInfer позволяет контролировать, из каких позиций TypeScript собирает кандидатов. Типичный сценарий: функция с config + defaults, где defaults должен подчиняться типу config, а не расширять его. Без NoInfer оба аргумента участвуют в инференсе, и T может стать шире, чем нужно.
Выводы
Теперь вы знаете, как TypeScript выводит типы переменных: через сбор кандидатов из аргументов, свёртку списка с учётом вариантности и приоритетов, а затем проверку результата на соответствие constraints. Алгоритм вывода типовых переменных — это тщательно продуманная, но не лишённая сюрпризов система. Две фазы (сбор и разрешение), вариантность, приоритеты и эвристики для пересечений создают поверхность, которую сложно угадать интуитивно, но которую можно понять, зная правила.
Ключевые практические выводы для разработчиков:
- Если функция принимает несколько аргументов одного дженерика — убедитесь, что они совместимы, иначе TypeScript выберет первый кандидат и отвергнет остальные.
- Не полагайтесь на супертипы для инференса: они не попадают в список кандидатов.
- В generic-контексте условные типы «замораживаются» — проектируйте API с этим ограничением.
- Используйте
NoInfer<T>в библиотечном коде для точного контроля над источниками вывода типов. - При странном поведении с пересечениями — перепишите типы явно или добавьте constraint.
Понимание алгоритма вывода типовых переменных позволяет писать более надёжные типовые абстракции и тратить меньше времени на отладку сложных generic-конструкций.
Источники:
- How TypeScript infers type variables — Nicolas Laurent (norswap)
- How TypeScript distributes unions — Nicolas Laurent (norswap)
- TypeScript Handbook: Generics
Если у вас есть примеры неожиданного поведения TypeScript с дженериками — делитесь в комментариях. Соберём коллекцию «типовых загадок» вместе.