Дистрибутивные условные типы
Голый параметр типа в условии распределяется по каждому члену объединения; обёртка в кортеж это отключает — а условие над never даёт never, потому что never — это пустое объединение.
Коллега пишет type NonEmpty<T> = T extends "" ? never : T, чтобы убирать пустые строки, тестирует на "a" | "", получает "a", ставит в прод. Через две недели другое место вызова передаёт never (тип исчерпанного объединения), и NonEmpty<never> тихо возвращает never — что верно, но по причине, которую никто в команде объяснить не может, и теперь обобщённый помощник молча выдаёт never там, где ждали обратно вход. Это не баг. Это дистрибуция, а never — это пустое объединение. Как только вы научитесь читать дистрибуцию точно, половина стандартных утилитных типов перестаёт быть магией.
Правило, сформулированное точно
Почему это важно? Потому что каждый утилитный тип для работы с объединениями — Exclude, Extract, Filter — работает именно благодаря этому правилу, а каждый неверный IsNever или IsString ломается из-за него. Когда проверяемый в условии тип — это голый параметр типа (T extends ..., где T стоит сам по себе, ни во что не обёрнутый) и T инстанцирован объединением, условие распределяется: оно применяется к каждому члену объединения по отдельности, а результаты объединяются обратно.
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>;
// ^? type A = string[] | number[]
// НЕ (string | number)[] — дистрибуция сначала разбила объединениеКомпилятор переписывает ToArray<string | number> как ToArray<string> | ToArray<number> = string[] | number[]. Проверка выполняется раз на каждый член.
Отключение: оберните параметр
Дистрибуция срабатывает только для голого параметра. Оберните его в одноэлементный кортеж — и объединение проверяется как единое целое:
type IsString<T> = [T] extends [string] ? true : false;
type B = IsString<string | number>;
// ^? type B = false ("string | number" не присваивается string — проверено целиком)
// Без обёртки дистрибуция даёт неожиданное объединение:
type IsStringNaked<T> = T extends string ? true : false;
type C = IsStringNaked<string | number>;
// ^? type C = boolean (true | false — распределено: string->true, number->false)Заметьте, C — это boolean. Это true | false — дистрибуция дала true для члена string и false для члена number, затем объединила их. Это самый частый сюрприз дистрибуции: предикат, который должен ответить «да или нет про тип целиком», вместо этого отвечает по членам и схлопывается в boolean.
never — это пустое объединение
never — это тип с нулём членов, пустое объединение. Дистрибуция условия над нулём членов даёт нуль результатов, а объединение ничего — это never:
type Wrap<T> = T extends unknown ? T[] : never;
type D = Wrap<never>;
// ^? type D = never (НЕ never[] — не было членов для обхода)Это знаменитая ловушка «условие над never даёт never». Wrap<never> — это не never[]; условие вообще не выполняется, потому что распределять не над чем. Чтобы заставить вычисление, когда вы действительно хотите проверить на never, отключите дистрибуцию:
type IsNever<T> = [T] extends [never] ? true : false;
type E = IsNever<never>;
// ^? type E = true (обёрнуто: пустое объединение присваивается [never])▸Почему это работает
Почему boolean тоже распределяется? boolean внутри — это true | false, объединение двух литеральных типов. Поэтому T extends true ? ... : ... над T = boolean выполняется дважды: раз для true и раз для false. Если вы видите, что условие загадочно возвращает boolean или вычисляет обе ветки, хотя вы передали один boolean, — вот почему. Оберните [boolean] extends [true], чтобы обрабатывать его атомарно.
Пересборка стандартной библиотеки через дистрибуцию
Exclude и Extract — чистая дистрибуция, по три токена каждый:
type MyExclude<T, U> = T extends U ? never : T; // выкинуть члены T, присваиваемые U
type MyExtract<T, U> = T extends U ? T : never; // оставить члены T, присваиваемые U
type MyNonNullable<T> = T extends null | undefined ? never : T;
type F = MyExclude<"a" | "b" | "c", "a">;
// ^? type F = "b" | "c"
// распределено: "a"->never, "b"->"b", "c"->"c"; never выпадает из объединенияКлючевая мысль: дистрибуция отображает каждый член либо в себя, либо в never, а never исчезает при объединении (X | never = X). Это и есть весь механизм фильтрации объединения — отобразите ненужные члены в never, и они пропадут. Общий фильтр строится так же:
type FilterByKind<T, K> = T extends { kind: K } ? T : never;
type Events = { kind: "click"; x: number } | { kind: "key"; code: string };
type G = FilterByKind<Events, "click">;
// ^? type G = { kind: "click"; x: number }Дано `type P<T> = T extends any ? T[] : never`; во что разрешается `type R = P<never>` и почему?
Дистрибутивное условие — как сортировщик почты: каждое письмо (член объединения) обрабатывается по одному и кладётся в исходящие. Чтобы сортировщик не вскрывал конверт и не обрабатывал каждое письмо отдельно — чтобы взять всю пачку как одно — параметр типа нужно ____.
- 01Сформулируйте точное условие, при котором условный тип распределяется, и приведите изменение в два токена, которое это отключает.
- 02Почему дистрибутивное условие над `never` возвращает `never`, а не отображённый результат, и как надёжно проверить на `never`?
- 03Почему Exclude, Extract и NonNullable — это просто дистрибуция, и какую роль играет `never` в результате?
Дистрибуция — единственное правило, благодаря которому работает типовое программирование на уровне объединений: голый параметр типа, проверяемый против объединения, выполняет условие раз на член и объединяет исходы. Обёртка в [T] вычисляет объединение целиком — необходимо для IsNever, предикатов в стиле IsString и всего, что должно отвечать про тип целиком, а не про каждый член. never — пустое объединение, поэтому дистрибуция над ним даёт never; boolean — это true | false и распределяется в обе ветки. Exclude/Extract/NonNullable — это дистрибуция, отображающая члены в never, который исчезает при пересборке. Теперь, когда обобщённый предикат схлопывается в boolean или фильтрованный тип вдруг возвращает never, вы первым делом смотрите: параметр голый? может ли он быть never?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.