Условные типы: тернарник на уровне типов, завязанный на совместимость
Условные типы выбирают ветку по вопросу «совместим ли T с U?» — а не по наследованию классов JS. Голый параметр типа дистрибутирует проверку по каждому члену объединения, а обобщённый T откладывает разрешение до инстанцирования.
Вы открываете @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 индивидуально: совпавшие члены становятся never (а never исчезает из объединения), несовпавшие — выживают. Без дистрибуции 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 Видит, что T — голый параметр типа, инстанцированный объединением 'a' | 'b'
- 2 Дистрибутирует: разбивает на ('a' extends 'a' ? never : 'a') | ('b' extends 'a' ? never : 'b')
- 3 Разрешает каждое: 'a' совместим с 'a' -> never; 'b' нет -> 'b'
- 4 Объединяет результаты веток: never | 'b', и never выпадает, остаётся 'b'
- 01Что именно проверяет `T extends U` в условном типе и почему читать это как наследование классов неверно?
- 02Что такое дистрибуция, когда она срабатывает и чему равно ToArray<string | number> для type ToArray<T> = T extends unknown ? T[] : never?
- 03Что значит, что условный тип «отложен», и как это даёт перегрузочные возвращаемые типы?
Теперь вы читаете T extends U ? X : Y как «совместим ли T с U?», знаете, что голый параметр типа дистрибутирует по объединениям, а [T] это подавляет, и знаете, что обобщённое условие остаётся отложенным до точки вызова. Дальше — ключевое слово infer подключается к части extends, чтобы захватить кусок сопоставленного типа — механизм за ReturnType, Awaited и извлечением головы/хвоста кортежа — и мы увидим, как позиция infer решает, схлопнутся ли несколько совпадений в объединение или в пересечение. Теперь, когда встречаете служебный тип, обрезающий объединение или оборачивающий каждый член отдельно — вы знаете: ищите голый T и дистрибутивное условие, никакого цикла нет.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.