Условные типы и распределение по объединению

условные типы≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Тип можно не только записать, но и вычислить. Запись T extends U ? X : Y — это тернарный оператор для типов: компилятор спрашивает, годится ли один тип на место другого, и выбирает ветку. А стоит подставить в такой тип объединение, как условие проверяется не один раз, а по разу для каждого члена — и результат тоже собирается объединением.

Разделы темы:·

Вид поля формы известен заранее: "checkbox", "text", "number". А тип значения от него зависит — у флажка это boolean, у остальных string. Раньше такую связь пришлось бы описывать двумя отдельными типами и следить за ней глазами. Условный тип записывает её одной строкой.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика одна, на пятой строке: Тип "string" не может быть назначен для типа "boolean". Компилятор подставил "checkbox" вместо T, проверил условие, взял ветку «да» и получил boolean — строке там места нет. Две строки выше прошли молча: у флажка ветка «да» и правда дала boolean, а у FieldValue<"text"> условие ложно — там string.

Синтаксис буквально повторяет тернарный оператор из части I, только вопрос задаётся не о значении, а о типе. T extends U ? X : Y читается как «если T годится на место U, то X, иначе Y». Вся работа происходит на этапе проверки: до браузера не доезжает ни условие, ни ветки. Поэтому на запуске и напечаталось да — ошибку мы проигнорировали, а строка так и осталась строкой.

Отсюда неудобство, с которым придётся жить всю главу: вычисленный тип негде посмотреть, console.log печатает значения. Зато о нём можно спросить — присвоить заведомо чужое значение и прочитать ответ в диагностике. Этим приёмом пользуется вся статья.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Третья строка ошиблась намеренно, и компилятор проговорился: Тип ""нет"" не может быть назначен для типа ""да"". Значит, IsString<"круг"> вычислился в "да": литерал "круг" годится на место string, сработала первая ветка. А четвёртая строка прошла без единого слова — и это тоже ответ: IsString<number> действительно "нет".

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

Слово extends сбивает с толку: в классах оно означает наследование. В условном типе вопрос другой и уже знакомый по статье про структурную типизацию — можно ли поставить значение типа T туда, где ждут U. Происхождение и имя типа роли не играют, лишние поля тоже не мешают.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

User ни от кого не наследуется и никакого { id: number } в глаза не видел, но подставить его туда, где ждут объект с числовым id, можно — значит, условие истинно. Ошибка на третьей строке это и подтверждает: Тип ""нет id"" не может быть назначен для типа ""есть id"". Объект с одним name проверку не прошёл, и четвёртая строка молчит: там честное "нет id".

Ветки условного типа — обычные типы, поэтому в ветку «иначе» можно вложить следующее условие. Получается цепочка, которая читается как if / else if.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три первых подстановки в порядке, ошибка на последней: Тип ""другое"" не может быть назначен для типа ""флаг"". Тип true до ветки «другое» не добрался — раньше сработала проверка extends boolean, ведь true годится на место boolean. А Date прошёл всю цепочку и вышел «другим»: это объект, ни под один примитив он не подходит.

Порядок веток важен так же, как порядок if / else if: сработает первое подошедшее условие, а не самое точное.

Проверить условием any нельзя: он годится на место любого типа, и любой тип годится на его место. Компилятор в этом случае ветку не выбирает — он возвращает обе, объединением.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика называет результат целиком: Тип ""может быть"" не может быть назначен для типа ""да" | "нет"". Ещё одна причина не пускать any в типы, которые что-то вычисляют: он не ломает их с грохотом, а тихо размазывает результат по всем вариантам.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Массив с числом и строкой — самое естественное содержимое для ToArray<string | number>, но компилятор его не принял. Диагностика вложенная, из тех, что разбирались в статье про чтение ошибок, и верхняя её строка объясняет всё: Тип "(string | number)[]" не может быть назначен для типа "string[] | number[]". Ожидался не «массив строк-или-чисел», а «или массив строк, или массив чисел» — два разных типа на выбор.

Проверим догадку: если тип и правда такой, каждый из двух массивов по отдельности должен пройти.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики. Значит, компилятор поступил так: разложил объединение на члены, применил условие к каждому — string дал string[], number дал number[] — и собрал результаты обратно объединением. Это и называется распределением условного типа по объединению.

Разбегается не объединение само по себе: Array<string | number> остаётся одним массивом. Распределение — поведение именно условного типа.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две ошибки, и обе по делу. Третья строка: Тип ""может быть"" не может быть назначен для типа ""да" | "нет"". — через параметр условие разбежалось и вернуло оба ответа сразу. Четвёртая: Тип ""да"" не может быть назначен для типа ""нет"". — Direct оказался просто "нет", потому что объединение целиком на место string не годится: числа туда не пускают.

Отсюда и способ выключить распределение — сделать так, чтобы слева стоял не голый параметр. Обычно обе стороны заворачивают в кортеж из одного элемента.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ошибки нет: Boxed<string | number> — это (string | number)[], ровно то, чего мы хотели с самого начала. Квадратные скобки тут не заклинание: [T] — уже не голый параметр, перебирать компилятору нечего, и он один раз проверяет кортеж против кортежа.

У правила есть следствие, на которое натыкаются все. Тип never — это объединение без единого члена, и распределять по нему нечего: ни одна ветка не выполнится ни разу, а результатом станет тот же never. Заодно это обещанное исключение из «условие T extends unknown истинно всегда»: истинность тут даже не проверяют — проверять не на чем.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Третья строка: Тип "never[]" не может быть назначен для типа "never". — ToArray<never> схлопнулся в never, и присвоить ему нельзя вообще ничего, даже пустой массив. Четвёртая молчит: у Boxed распределение выключено, условие проверилось один раз и честно дало never[].

Условный типПодставилиЧто вычислилось
T extends string ? "да" : "нет"string | number"да" | "нет" — разбежался по членам
[T] extends [string] ? "да" : "нет"string | number"нет" — проверка целиком
T extends string ? "да" : "нет"nevernever — перебирать нечего
[T] extends [string] ? "да" : "нет"never"да" — never годится на любое место
(string | number) extends string ? …ничего, параметра нет"нет" — распределяться нечему

Распределение — не курьёз для собеседований. На нём стоят две утилиты стандартной библиотеки, которые пригождаются в обычном коде постоянно: вычесть из объединения лишнее и оставить только нужное. Третьей в этом ряду обычно называют NonNullable — она убирает null и undefined, — но записана она иначе, и к этому мы вернёмся.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три диагностики подряд, и каждая показывает вычисленный тип. Exclude<Method, "GET"> оставил "POST" | "PUT" | "DELETE" — из объединения вычли один член. Extract<Method, "GET" | "HEAD"> дал "GET": остаётся пересечение, а "HEAD" в Method не нашёлся и просто пропал. NonNullable убрал null и undefined — тот самый разговор из статьи про strictNullChecks, только теперь на уровне типов.

Внутри у Exclude и Extract нет ничего, кроме условного типа с распределением: каждая пишется в одну строку, и вы уже умеете такие писать. Напишем заодно и свой NonNullable — тем же приёмом.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

MyExclude проверяет каждый член отдельно: подошёл под U — заменяем его на never, не подошёл — оставляем как есть. Дальше срабатывает свойство never, из-за которого он и кажется странным типом: в объединении он исчезает, потому что не описывает ни одного значения. Из never | "POST" | "PUT" | never остаётся "POST" | "PUT" — это и подтвердила ошибка на пятой строке.

С MyNonNullable та же история: распределение проверяет каждый член и выкидывает пустые. Работает — а вот NonNullable из библиотеки устроен не так. До TypeScript 4.8 он был ровно такой строкой, а теперь записан без всякого условия: type NonNullable<T> = T & {};. Пересечение с {} ничего не добавляет тому, что и так не пустое, а null и undefined в него не влезают и из объединения пропадают: NonNullable<string | null | undefined> компилятор показывает просто как string. Условие и распределение тут не нужны вовсе — полезное напоминание, что одну и ту же задачу система типов часто решает двумя разными записями.

Тот же приём работает не только со строками. Если объединение состоит из объектов, Extract вытаскивает из него нужный вариант по образцу.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Образец здесь — { kind: "circle" }: под него годится только первый член объединения, второй отсеялся в never и исчез. Зато у переменной circle теперь доступно поле r, которого у прямоугольника нет: компилятор знает, что вариант остался ровно один. Шестая строка ошибается ожидаемо — Тип ""rect"" не может быть назначен для типа ""circle"".

Пока T не подставлен, условие вычислять не из чего. Компилятор откладывает его и держит тип нераскрытым — а подойти нераскрытому условию не может ничто.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика короткая: Тип "string" не может быть назначен для типа "Res<T>". Внутри функции Res<T> — не строка и не число, а обещание вычислить одно из двух потом. Выкручиваются перегрузками или утверждением типа, но чаще это знак, что задача решается проще: условный тип хорош там, где типы вычисляются, а не там, где функция что-то выбирает во время выполнения.

  • T extends U ? X : Y — тернарный оператор для типов; вопрос в нём не «наследует ли», а «годится ли T на место U».
  • Вычисленный тип не напечатать, но можно вытянуть из диагностики: присвойте заведомо чужое значение и прочитайте ожидаемый тип.
  • Подставленное объединение разбегается: условие применяется к каждому члену, а результаты снова собираются объединением.
  • Распределение включается только для голого параметра слева от extends; обёртка [T] extends [U] его выключает.
  • never — объединение без членов: распределение по нему не выполняет ни одной ветки и схлопывает результат в never.
  • Exclude и Extract — по одному условному типу с распределением каждая; never в ветке «да» работает как «выкинуть этот член». А NonNullable с версии 4.8 решает свою задачу вообще без условия — пересечением T & {}.

1/4Тип объявлен как type Check<T> = T extends string ? "да" : "нет". Что вычислится у Check<string | number>?

2/4Нужно, чтобы условие проверило объединение целиком, а не по членам. Как это записывают?

3/4Тип ToArray<T> = T extends unknown ? T[] : never. Чему равен ToArray<never>?

4/4Почему type Direct = (string | number) extends string ? "да" : "нет" даёт "нет", а через параметр типа получалось "да" | "нет"?

Оставить в объединении только строки

Тип OnlyStrings<T> должен выбрасывать из объединения все нестроковые члены: у Mixed остаётся один string. Сейчас он ничего не делает и пропускает число молча — почините его условным типом.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Условие применяется к каждому члену объединения по отдельности: строку оставляем как есть, всё остальное превращаем в never — и он исчезает из объединения сам.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание на ветку «да»: там именно T, а не string. Разница видна на литералах — у объединения "круг" | 42 версия с T сохранит литеральный тип "круг", а версия со string расширит его до любой строки. Это, кстати, готовый Extract<T, string>.

Выключить распределение

ToArray<string | number> должен давать один массив, куда влезут и строки, и числа. Сейчас он даёт выбор из двух массивов, и смешанный массив в него не проходит. Условие менять нельзя — измените только то, что стоит слева и справа от extends.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обе стороны заворачиваем в кортеж из одного элемента: слева перестаёт стоять голый параметр типа, и распределение не включается.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

И честное замечание: в этом конкретном типе условие вообще ни для чего не нужно — type ToArray<T> = T[] работает так же и не разбегается, потому что распределяются только условные типы. Кортеж-обёртка нужна там, где условие настоящее и его результат зависит от проверки.

Проверка на пустой тип

Тип IsNever<T> должен отвечать "пусто" для never и "есть значения" для всего остального. Записанный в лоб, он ведёт себя странно: на never компилятор ругается, что присвоить нельзя ничего. Объясните, что произошло, и почините.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

На четвёртой строке диагностика Тип ""пусто"" не может быть назначен для типа "never". — то есть IsNever<never> вычислился не в "пусто", а в never. Причина уже знакомая: never — объединение без членов, распределение не выполнило ни одной ветки. Лечится кортежем.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь условие проверяется один раз: [never] годится на место [never], ветка «да» срабатывает. Прямое сравнение T === never в типах невозможно в принципе — это единственный способ такую проверку записать.