open atlas
↑ К треку
Система типов TypeScript вглубь TS · 04 · 01

Условные типы: тернарник на уровне типов, завязанный на совместимость

Условные типы выбирают ветку по вопросу «совместим ли T с U?» — а не по наследованию классов JS. Голый параметр типа дистрибутирует проверку по каждому члену объединения, а обобщённый T откладывает разрешение до инстанцирования.

TS Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Вы открываете @types/node и видите ReturnType, NonNullable, Exclude — каждый в одну строку, заканчивающуюся на ? X : Y. Наводитесь на Exclude<"a" | "b" | "c", "a"> и подсказка показывает "b" | "c", хотя Exclude написан под один тип, а не под три. ?:, который проходит по объединению без всякого цикла. Это условный тип, а цикл — это его свойство, называемое дистрибутивностью.

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

extends здесь означает «совместим с», а не «потомок класса»

Почему это важно? Если читать extends как наследование классов, вы ожидаете, что через проверку проходят только более узкие типы — и тогда широкий объект, удовлетворяющий узкому интерфейсу, кажется невозможным. Как только вы видите в этом проверку совместимости, каждый служебный тип в стандартной библиотеке становится очевидным.

Условный тип — это тернарник на уровне типов:

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

type A = IsString<"hello">; // ^? true
type B = IsString<number>;  // ^? false

Слово extends — то же ключевое слово, что используют классы, и эта путаница — главный источник недопонимания. В условном типе T extends U задаёт один вопрос: является ли каждое значение T допустимым значением U? — то есть совместим ли T с U? Это не имеет отношения к цепочкам прототипов JS или class X extends Y. Структурная совместимость (юнит 01) — это и есть вся проверка.

type T1 = { a: 1; b: 2 } extends { a: 1 } ? "yes" : "no"; // ^? "yes"
//   более широкий объект СОВМЕСТИМ с более узким
type T2 = 42 extends number ? "yes" : "no";               // ^? "yes"
type T3 = string extends "a" ? "yes" : "no";              // ^? "no"
//   string шире литерала "a", поэтому НЕ совместим

Голые параметры типа дистрибутируют по объединениям

Вот поведение, которое удивило вас в подсказке. Когда проверяемый тип — голый параметр типа (T стоит на левой стороне extends без обёрток) и вы инстанцируете его объединением, TypeScript разбивает объединение и применяет условие к каждому члену по отдельности, а затем объединяет результаты:

type ToArray<T> = T extends unknown ? T[] : never;

type R = ToArray<string | number>;
// ^? string[] | number[]
//   НЕ (string | number)[] — объединение дистрибутировано:
//   ToArray<string> | ToArray<number>  =>  string[] | number[]

T extends unknown ? всегда берёт истинную ветку, так что это просто «обернуть в массив» — но поскольку T голый, оно выполняется по разу на каждый член объединения. Это дистрибутивные условные типы. Полный разбор — в юните 05; здесь усвойте триггер: голый параметр типа + аргумент-объединение.

Полезное следствие: дистрибуция над never даёт never, потому что never — это пустое объединение (ноль членов для перебора):

type Z = ToArray<never>; // ^? never  — а не never[]
Почему это работает

Зачем вообще дистрибутировать? Замысел в том, чтобы условные типы вели себя так, будто само объединение «задаёт вопрос». Exclude<T, U> = T extends U ? never : T работает только потому, что каждый член T проверяется на совместимость с U индивидуально: совпавшие члены становятся nevernever исчезает из объединения), несовпавшие — выживают. Без дистрибуции Exclude<"a" | "b", "a"> проверял бы всё объединение "a" | "b" на совместимость с "a", получил бы false и вернул "a" | "b" без изменений — бесполезно.

Отключение дистрибуции обёрткой-кортежем

Иногда нужно сравнить объединение как единое целое. Оберните обе стороны в одноэлементный кортеж, чтобы T перестал быть голым:

type IsExactlyStringOrNumber<T> =
  [T] extends [string | number] ? true : false;

type Y = IsExactlyStringOrNumber<string | number>; // ^? true
//   [string | number] extends [string | number] — проверено целиком, без разбиения

type N = IsExactlyStringOrNumber<string | boolean>; // ^? false

[T] делает параметр неголым, поэтому дистрибуция подавляется и объединение проверяется за один проход. Идиома [T] extends [U] — каноничный приём «не дистрибутировать»; вы встретите её повсюду в типовом коде библиотек.

Отложенные условия: когда T ещё обобщённый

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

function unwrap<T>(x: T): T extends Promise<infer U> ? U : T {
  // внутри тела возвращаемый тип всё ещё *неразрешённое* условие:
  // T extends Promise<infer U> ? U : T  — TS не может его вычислить, T открыт
  throw new Error("impl elided");
}

const a = unwrap(Promise.resolve(5)); // ^? number   — разрешено в точке вызова
const b = unwrap("plain");            // ^? "plain"

Эта отложенность и позволяет моделировать перегрузочные возвращаемые типы одной сигнатурой: конкретный T вызывающей стороны определяет, какая ветка сработает. Это также значит, что условие над обобщённым T ленивое — совместимость двух неразрешённых условий держится, только когда их проверяемый/extends/ветки структурно совпадают, поэтому обобщённый условный код иногда отказывается упрощаться, пока вы его не инстанцируете.

Цена: где условный тип перестаёт окупаться

Дистрибуция — ещё и комбинаторная ловушка. Условие выполняется по разу на каждый член объединения, и если его ветки строят новые объединения, число членов перемножается. TypeScript ограничивает объединение 100 000 членами и затем сдаётся с Expression produces a union type that is too complex to represent; кортеж, переросший внутренний предел, даёт родственное Expression produces a tuple type that is too large to represent. Достичь потолка объединения можно одним слишком жадным дистрибутивным условием, которому скормили большой keyof — и сбой проявится не как чистый тип, а как any плюс красное подчёркивание в несвязанных точках вызова.

Senior-критерий: условный тип даёт вам возвращаемый тип, который автоматически отслеживает вход, с нулевой стоимостью в рантайме. Счета приходят на этапе компиляции и в читаемости ошибок. Когда ветка идёт не так, подсказка показывает всё неразрешённое условие — T extends Promise<infer U> ? U : T extends ... вложенное на четыре уровня — вместо имени, и junior, читающий точку вызова, не понимает, почему выведенный тип — never. Тянитесь к условию, когда тип действительно меняется с аргументом (случай unwrap/Awaited). Когда нужно лишь одно решение в рантайме, размеченное объединение плюс обычный if читается дешевле, компилируется дешевле и даёт сообщение об ошибке, по которому коллега может действовать. Если тип и значение должны выводиться вместе (карта параметров роутера, тип строки ORM), предпочтите шаг кодогенерации, выдающий плоские именованные типы, вместо башни дистрибутивных условий — сгенерированный type Row = { id: number; ... } человек может навести и понять; условие, его породившее, — нет.

lesson.inset.warning

Числа, которые стоит запомнить, прежде чем дебажить медленный редактор. Условные типы рекурсивныAwaited снимает вложенные промисы, повторно входя в себя. TypeScript допускает примерно 1000 уровней хвосторекурсивного инстанцирования условия (предел устранения хвостовой рекурсии; нехвостовая рекурсия взрывается куда раньше, около глубины инстанцирования 50, с Type instantiation is excessively deep and possibly infinite). Когда тип взрывается, tsc --extendedDiagnostics — это инструмент: следите за Instantiation count и Check time. Одно патологическое рекурсивное условие способно качнуть Check time с ~1с до 20с+ на проекте среднего размера и уронить IntelliSense редактора с мгновенного до многосекундного на нажатие — тот же type-checker работает в редакторе, так что тип, медленно компилирующийся, медленно подсказывает. Если Instantiation count для одного файла прыгает в миллионы, виновник почти всегда — условие (часто дистрибутивное над большим объединением).

Викторина

Дано type ToArray<T> = T extends unknown ? T[] : never. Чему равно ToArray<string | number>?

Расставь шаги по порядку

Упорядочьте шаги, которыми TypeScript разрешает Exclude<'a' | 'b', 'a'>, где Exclude<T, U> = T extends U ? never : T:

  1. 1 Видит, что T — голый параметр типа, инстанцированный объединением 'a' | 'b'
  2. 2 Дистрибутирует: разбивает на ('a' extends 'a' ? never : 'a') | ('b' extends 'a' ? never : 'b')
  3. 3 Разрешает каждое: 'a' совместим с 'a' -> never; 'b' нет -> 'b'
  4. 4 Объединяет результаты веток: never | 'b', и never выпадает, остаётся 'b'
Вспомните перед уходом
  1. 01
    Что именно проверяет `T extends U` в условном типе и почему читать это как наследование классов неверно?
  2. 02
    Что такое дистрибуция, когда она срабатывает и чему равно ToArray<string | number> для type ToArray<T> = T extends unknown ? T[] : never?
  3. 03
    Что значит, что условный тип «отложен», и как это даёт перегрузочные возвращаемые типы?
Итог

Теперь вы читаете T extends U ? X : Y как «совместим ли T с U?», знаете, что голый параметр типа дистрибутирует по объединениям, а [T] это подавляет, и знаете, что обобщённое условие остаётся отложенным до точки вызова. Дальше — ключевое слово infer подключается к части extends, чтобы захватить кусок сопоставленного типа — механизм за ReturnType, Awaited и извлечением головы/хвоста кортежа — и мы увидим, как позиция infer решает, схлопнутся ли несколько совпадений в объединение или в пересечение. Теперь, когда встречаете служебный тип, обрезающий объединение или оборачивающий каждый член отдельно — вы знаете: ищите голый T и дистрибутивное условие, никакого цикла нет.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources1
expand
  1. 01

Trademarks belong to their respective owners. Editorial reference only.