Реклама
Селектел, перетяжка, 22.06
Селектел, перетяжка, 22.06
Селектел, перетяжка, 22.06

Как TypeScript выводит типы переменных: разбор алгоритма

Почему TypeScript иногда выводит unknown вместо конкретного типа? Разбираем двухфазный алгоритм инференса type variables: сбор кандидатов, ковариантность, контравариантность, приоритеты и странное поведение интерсекций.

Обложка: Как TypeScript выводит типы переменных: разбор алгоритма

Если вы когда-нибудь ловили себя на мысли, что 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) кандидат.

			function f<A, B, C>(x: A, y: (a: B) => C) {}
f(true, (it: number) => "hello")
// A = boolean  (ковариант)
// B = number   (контравариант — вход callback)
// C = string   (ковариант — выход callback)
		

Условные типы и infer

Когда TypeScript встречает условный тип, кандидаты собираются только из той ветки, которая теоретически может сработать. Но важно: само условие не добавляет кандидатов. Запись T extends Foo НЕ добавляет Foo как кандидат для T.

			type Pair<A, B> = A extends string ? Map<A, B> : [A, B]
function f<A, B>(x: Pair<A, B>) {}

f([1, 2])           // A = number, B = number (вторая ветка)
f(new Map([["a", 1]])) // A = string, B = number (первая ветка)
f(["a", "b"])       // ошибка: candidates из второй ветки, но проверка идёт по первой!
		

Это классическая ловушка: кандидаты собираются из одной ветки, а типовая проверка — из другой. TypeScript не вычисляет условные типы на этапе инференса.

Распределение по union

Если source type — union, а target — не «голый» type parameter, TypeScript проходит по каждой ветке union отдельно и собирает кандидатов из всех веток в один список.

			declare function f<T>(x: Foo<T>): T
declare const u: Foo<Dog> | Foo<Cat>
f(u) // candidates: [Dog, Cat]
		

Что НЕ учитывается при сборе

  • Условие условного типа (только ветки).
  • Constraints (<T extends Foo>) — они проверяются позже, на этапе разрешения.
  • Дефолтные значения type parameter (<T = unknown>) — используются только если список кандидатов пуст.
  • Супертипы source type.

Фаза 2: разрешение кандидатов

После сбора TypeScript сворачивает каждый список в единственный тип. Алгоритм зависит от вариантности и содержимого списка.

Ковариантные кандидаты

  1. Если все кандидаты — литералы одного базового типа, они объединяются в union.
  2. Иначе ищется кандидат, который является строгим супертипом всех остальных. Если найден — он выбирается.
  3. Если нет — выполняется left-reduce: начинаем с первого кандидата, идём по списку, заменяя текущий на супертип, если встречаем его.
			function f<T>(x: T, y: T) {}

f("a", "b")      // T = "a" | "b"  (литералы одного базового типа → union)
f(dog, animal)   // T = Animal     (Animal — супертип Dog)
f(dog, cat)      // T = Dog        (нет общего супертипа в списке → первый!)
		
Важно:
Если в списке кандидатов нет общего супертипа, TypeScript выбирает первый элемент. Это частая причина неожиданных ошибок при передаче разнородных аргументов одного дженерика.

Контравариантные кандидаты

Для контравариантных кандидатов логика зеркальная: ищется общий подтип, иначе — left-reduce с подтипами. Если оба списка (ковариантный и контравариантный) непусты, контравариантный результат обычно побеждает — за исключением случаев, когда ковариантный результат является подтипом контравариантного.

			function f<T>(x: T, sink: (t: T) => void) { sink(x) }

f(new Dog(), (_: Animal) => {})  // T = Dog (ковариант Dog ⊂ контравариант Animal → ковариант побеждает)
f(new Animal(), (_: Dog) => {})  // T = Dog (контравариант побеждает, но вызов всё равно ошибка)
		

Приоритеты кандидатов

Каждый кандидат помечается приоритетом. Кандидат с более высоким приоритетом стирает все кандидаты с более низким приоритетом. Основные приоритеты: None (0), NakedTypeVariable (1), MappedTypeConstraint (32), ReturnType (128), LiteralKeyof (256).

Приоритеты MappedTypeConstraint, ReturnType и LiteralKeyof — «комбинационные»: при них результатом становится union или intersection всех кандидатов, а не единственный тип.

Пересечения: загадочное поведение

Одно из самых неинтуитивных мест — вывод через пересечения. Рассмотрим несколько примеров.

			function foo<A, B>(it: A & B) {}
foo(42) // A = unknown, B = unknown
// TypeScript не может решить, кому отдать number — и сдаётся
		
			function foo<A>(it: A & { x: number }) {}
foo({ x: 42, y: 42 }) // A = { x: number, y: number }
// Вместо "остатка" { y: number } захвачен весь объект!
		
			function bar<A>(it: A & number) {}
bar(42 as number & { brand: "USD" }) // A = { brand: "USD" }

function baz<A>(it: A & string) {}
baz("a" as "a" & { brand: 1 }) // A = "a" & { brand: 1 }
		

Почему так? Это эвристики компилятора, направленные на максимизацию полезности захваченного типа. В первом случае полезнее получить весь объект, во втором — сохранить литеральную точность строки. Порядок операндов пересечения не влияет на результат.

Вывод в generic-контексте

Когда инференс происходит внутри другой generic-функции, условные типы не могут быть вычислены, потому что type variables ещё не связаны. Это ломает паттерны вроде Exclude, если вызывать их через generic-обёртку.

			function bar<X>(x: Exclude<X, number>) {}
function quuz<V>(x: Exclude<V, number>) { bar(x) }
// Работает только потому, что типы структурно идентичны!
// Условие Exclude<V, number> не вычисляется внутри quuz.
		

В типовых enterprise-проектах, где активно используются типовые утилиты для валидации API (например, в проектах на NestJS или с Zod), это ограничение часто приводит к необходимости явно прописывать generic-аргументы или реструктурировать типы.

NoInfer: как управлять выводом

Начиная с TypeScript 5.4, в языке есть встроенный вспомогательный тип NoInfer<T>. Он блокирует сбор кандидатов для T из той позиции, где используется NoInfer<T>.

			function createAPI<T>(config: T, defaults: NoInfer<T>) {}

// Без NoInfer defaults тоже участвует в выводе T
createAPI({ timeout: 5000 }, { timeout: 3000, retries: 3 })
// T выводится как { timeout: number; retries: number }
// И первый аргумент не пройдёт проверку, если retries обязателен!

// С NoInfer<T> для defaults: T выводится ТОЛЬКО из config
createAPI({ timeout: 5000 }, { timeout: 3000 })
// T = { timeout: number }, defaults подстраивается под него
		

Это особенно полезно в библиотечном коде, где нужно разделить «источник истины» для вывода типа и позиции, которые должны ему подчиняться.

FAQ

Часто задаваемые вопросы
1
Почему TypeScript иногда выбирает Dog вместо Animal, хотя оба аргумента подходят?

При разрешении ковариантных кандидатов TypeScript ищет тип из списка, который является супертипом всех остальных. Если такого нет — остаётся первый кандидат. Animal не попадает в список кандидатов автоматически: кандидаты собираются только из структурных частей переданных аргументов, а не из их супертипов.

2
Может ли TypeScript вывести string из аргумента типа number?

Нет. TypeScript собирает кандидатов только из source type и его структурных компонентов. Если вы передали number, кандидатом может быть только number или его части (например, литерал 42), но не string. Это фундаментальное ограничение алгоритма.

3
Почему Exclude не работает внутри generic-функции?

Потому что условные типы не вычисляются, когда type variable ещё не связан. Внутри function bar<X>(x: Exclude<X, number>) условие X extends number остаётся невычисленным. Чтобы тип прошёл проверку, нужно либо явно указать X при вызове, либо гарантировать структурную идентичность типов.

4
Что делать, если интерсекция ведёт себя непредсказуемо?

Если TypeScript захватывает слишком много в type parameter, попробуйте изменить структуру типа: вынесите «фиксированную» часть в отдельный параметр или используйте NoInfer для блокировки вывода. В крайнем случае — явно указывайте generic-аргументы при вызове.

5
Как NoInfer помогает в реальных проектах?

NoInfer позволяет контролировать, из каких позиций TypeScript собирает кандидатов. Типичный сценарий: функция с config + defaults, где defaults должен подчиняться типу config, а не расширять его. Без NoInfer оба аргумента участвуют в инференсе, и T может стать шире, чем нужно.

Выводы

Теперь вы знаете, как TypeScript выводит типы переменных: через сбор кандидатов из аргументов, свёртку списка с учётом вариантности и приоритетов, а затем проверку результата на соответствие constraints. Алгоритм вывода типовых переменных — это тщательно продуманная, но не лишённая сюрпризов система. Две фазы (сбор и разрешение), вариантность, приоритеты и эвристики для пересечений создают поверхность, которую сложно угадать интуитивно, но которую можно понять, зная правила.

Ключевые практические выводы для разработчиков:

  1. Если функция принимает несколько аргументов одного дженерика — убедитесь, что они совместимы, иначе TypeScript выберет первый кандидат и отвергнет остальные.
  2. Не полагайтесь на супертипы для инференса: они не попадают в список кандидатов.
  3. В generic-контексте условные типы «замораживаются» — проектируйте API с этим ограничением.
  4. Используйте NoInfer<T> в библиотечном коде для точного контроля над источниками вывода типов.
  5. При странном поведении с пересечениями — перепишите типы явно или добавьте constraint.
Понимание алгоритма вывода типовых переменных позволяет писать более надёжные типовые абстракции и тратить меньше времени на отладку сложных generic-конструкций.
Автор статьиtproger

Источники:

Если у вас есть примеры неожиданного поведения TypeScript с дженериками — делитесь в комментариях. Соберём коллекцию «типовых загадок» вместе.