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

Дистрибутивные условные типы

Голый параметр типа в условии распределяется по каждому члену объединения; обёртка в кортеж это отключает — а условие над never даёт never, потому что never — это пустое объединение.

TS Senior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

Коллега пишет 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>` и почему?

Закончи аналогию

Дистрибутивное условие — как сортировщик почты: каждое письмо (член объединения) обрабатывается по одному и кладётся в исходящие. Чтобы сортировщик не вскрывал конверт и не обрабатывал каждое письмо отдельно — чтобы взять всю пачку как одно — параметр типа нужно ____.

Вспомните перед уходом
  1. 01
    Сформулируйте точное условие, при котором условный тип распределяется, и приведите изменение в два токена, которое это отключает.
  2. 02
    Почему дистрибутивное условие над `never` возвращает `never`, а не отображённый результат, и как надёжно проверить на `never`?
  3. 03
    Почему Exclude, Extract и NonNullable — это просто дистрибуция, и какую роль играет `never` в результате?
Итог

Дистрибуция — единственное правило, благодаря которому работает типовое программирование на уровне объединений: голый параметр типа, проверяемый против объединения, выполняет условие раз на член и объединяет исходы. Обёртка в [T] вычисляет объединение целиком — необходимо для IsNever, предикатов в стиле IsString и всего, что должно отвечать про тип целиком, а не про каждый член. never — пустое объединение, поэтому дистрибуция над ним даёт never; boolean — это true | false и распределяется в обе ветки. Exclude/Extract/NonNullable — это дистрибуция, отображающая члены в never, который исчезает при пересборке. Теперь, когда обобщённый предикат схлопывается в boolean или фильтрованный тип вдруг возвращает never, вы первым делом смотрите: параметр голый? может ли он быть never?

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.