Объединения: значение одного из нескольких типов

сужение≈ 10 минут

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

Артикул приходит то числом, то строкой; ширина задаётся пикселями или словом auto. Для таких случаев в TypeScript есть объединение: запись string | number означает «одно из двух, а какое именно — на этой строке ещё неизвестно». Из этого «неизвестно» растёт главное правило статьи.

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

Объединение собирается вертикальной чертой: слева и справа от неё — типы, любой из которых допустим. Черта тут не «или-или» из логики, а перечисление: string | number — это множество значений, в котором есть все строки и все числа, и ничего больше.

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

Диагностика знакомая по главе про базовые типы: Тип "boolean" не может быть назначен для типа "string | number". Обратите внимание, как компилятор пишет само объединение — той же чертой, что и вы. Этот текст ещё не раз встретится в сообщениях, и читать его нужно как «список допустимого».

В параметрах функции объединение работает так же: внутрь пускают любой из вариантов и не пускают всё остальное.

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

Третий вызов — тот самый случай, ради которого типы и нужны. Компилятор сказал Аргумент типа "number[]" нельзя назначить параметру типа "string | number", а движок отработал молча и напечатал артикул: 1024: String([1024]) честно даёт "1024". Ошибка есть, а внешне всё в порядке — до того дня, когда в массиве окажется два элемента.

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

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

Объединение читается как «одно из», а не «и то и другое сразу». Значение всегда одного конкретного типа — просто в тексте программы не написано, какого именно.

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

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

Диагностика приходит с вложенной причиной, ровно как разбиралось в статье про чтение сообщений: Свойство "toUpperCase" не существует в типе "string | number", а строкой ниже — Свойство "toUpperCase" не существует в типе "number". Первая строка называет проблему, вторая — виновника: конкретный вариант, на котором обещание ломается.

А дальше пример показывает, зачем всё это затевалось. Первый вызов печатает A-1024, второй падает с TypeError: id.toUpperCase is not a function. Предупреждение прозвучало ещё до запуска и показывало пальцем ровно на числовой вариант — тот, что и сломался.

Что же тогда разрешено? Всё, что есть у каждого варианта. toString объявлен и у строки, и у числа — значит, он есть и у объединения, и такой код проходит без единой диагностики.

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

«Общее» считается по свойствам, а не по родству типов. Варианты могут быть сколь угодно разными: если нужное имя есть у обоих — доступ открыт, и тип результата тоже станет объединением.

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

Запуск, кстати, напечатает и ДА: передали-то мы строку, а у неё toUpperCase есть. Компилятор отвечает не за конкретный вызов, а за все возможные — и запрещает то, что сломается хотя бы на одном варианте.

С объектами правило то же самое, только выглядит нагляднее: доступны поля, объявленные во всех вариантах.

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

Вложенная причина здесь сообщает Свойство "bark" не существует в типе "Cat" — назван первый вариант, который не подошёл. И снова предупреждение сбылось буквально: Мурка напечаталась, а следующая строка дала TypeError: pet.bark is not a function.

Объединение — не объект, в котором есть все поля сразу. Это выбор из вариантов, и до проверки видна только их общая часть.

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

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

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

Нет — и по той же причине. Оператор + у строк и у чисел делает разное, а безопасного общего поведения у него нет.

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

Текст диагностики другой — Оператор "+" невозможно применить к типам "string | number" и "number", код TS2365. Оба вызова на исполнении что-то напечатали, но одно из этих «что-то» — склейка строк вместо сложения, тот самый сюжет из части I про преобразование типов.

Самое частое объединение в реальном коде состоит не из string и number, а из литеральных типов — тех самых, где «круг» был типом. Перечислили допустимые значения через черту — получили тип, в который больше ничего не влезет.

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

Черта перед первым вариантом — не опечатка, а привычное оформление длинных объединений: так строки выравниваются и любой вариант удаляется целой строкой. Компилятор к ведущей черте относится спокойно.

Ценность видна по выводу: движок напечатал статус: готово и глазом не моргнул. Ровно так эта опечатка и жила бы, будь параметр объявлен как string, — уехала бы в хранилище, в отчёт, в чужой код. Объединение литералов ловит её на сборке, а иногда компилятор ещё и подсказывает правильный вариант.

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

Здесь код не TS2345, а TS2820, и текст с продолжением: Тип ""готово"" невозможно присвоить типу "Status". Вы имели в виду ""готова""? Компилятор сравнил строку с вариантами, нашёл близкий и предложил замену. Отдельный код нужен как раз для того, чтобы редактор смог показать быстрое исправление.

Смешивать литералы с обычными типами тоже можно — так описывают значения вида «число или ключевое слово». В CSS такого добра много.

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

Объединение литералов — это список допустимых значений, записанный в типе. Тип string разрешает миллион строк, из которых верны три.

Объединение — множество побольше, каждый вариант — множество поменьше внутри него. Отсюда направление совместимости: string кладётся в string | number свободно, а обратно — нет. Обратное присваивание означало бы обещание, которое нечем подкрепить.

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

Вложенная причина опять называет виновника: Тип "number" не может быть назначен для типа "string". И опять диагностика сбывается — второй вызов печатает undefined, потому что у числа нет свойства length. Не падение, а тихая чепуха в логах: такие ошибки ищут дольше всего.

Что делаемЧто скажет компилятор
Кладём string в string | numberмолчит — вариант из списка
Кладём string | number в stringTS2322
Передаём number в параметр string | numberмолчит
Передаём string | number в параметр stringTS2345
Читаем свойство, которое есть у всех вариантовмолчит
Читаем свойство, которого нет хотя бы у одногоTS2339

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

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

Сообщение Аргумент типа ""A-3"" нельзя назначить параметру типа "never" сначала обескураживает. Логика такая: push у массива строк принимает строки, у массива чисел — числа, а какой из двух перед нами, неизвестно. Значит, добавить можно лишь то, что подходит обоим сразу, — а таких значений не существует. Пустой набор компилятор и называет never; отдельная статья главы посвящена ему целиком.

Скобки в типах значат ровно то же, что в арифметике. (string | number)[] — массив из «одного из», string[] | number[] — «один из» массивов.

Ограничение «только общее» не тупик, а требование определённости: хотите методы строки — сначала покажите, что перед вами строка. Доказательством служит обычная проверка, которая работала бы и в чистом JavaScript.

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

Ни одной диагностики, хотя обе строки по отдельности запрещены. Компилятор проследил поток выполнения и понял, в какую ветку какие значения попадают. Приём называется сужением типа, и ему посвящена почти вся эта глава — от typeof до собственных проверок.

Второй честный выход — не выбирать вариант, а привести значение к общему знаменателю. Часто это проще проверки: функции вроде String принимают что угодно.

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

А есть выход ложный, и встретите вы его в чужом коде обязательно. Оператор as ничего не проверяет — он утверждает. Компилятор соглашается смотреть на значение как на строку, потому что строка входит в объединение, и замолкает.

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

Диагностик ноль, а второй вызов врёт. В отличие от примера с toUpperCase, здесь даже не будет TypeError: у числа просто нет свойства length, и вместо ошибки в логах появляется undefined. Разница с проверкой принципиальная — typeof живёт до запуска и после него, as исчезает при компиляции и не оставляет после себя ни одной инструкции.

Проверка спрашивает у значения. as сообщает компилятору. Первое можно перепроверить на исполнении, второе — только на словах.

  • Объединение записывается вертикальной чертой и означает «одно из перечисленного»: string | number — либо строка, либо число, и ничего третьего.
  • Компилятор собирает объединения и сам — когда ветки кода возвращают значения разных типов.
  • До проверки у объединения доступны только свойства, которые есть у каждого варианта; на остальных — TS2339, и вложенная причина называет виновный вариант.
  • Совместимость работает в одну сторону: вариант помещается в объединение, объединение в вариант — нет (TS2322 при присваивании, TS2345 при вызове).
  • Объединение литералов описывает список допустимых значений и ловит опечатки, которые с типом string дожили бы до продакшена.
  • Выйти из объединения честно можно проверкой (это сужение, тема следующих статей) или приведением к общему знаменателю; as лишь заставляет компилятор замолчать.

1/4Параметр объявлен как string | number, и в теле вызывается id.toUpperCase(). Что произойдёт?

2/4Функция need(text: string) ждёт строку. Мы передаём в неё значение типа string | number. Как отреагирует компилятор?

3/4Параметр объявлен как string[] | number[], и в теле написано list.push("A-3"). Что скажет компилятор?

4/4В функции с параметром string | number написано (value as string).length, и функцию вызвали с числом 1024. Что получится?

Этикетка для артикула

Функция должна печатать артикул заглавными буквами — и для строкового артикула, и для числового. Сейчас компилятор даёт TS2339, а второй вызов падает с TypeError. Почините так, чтобы диагностик не осталось и оба вызова что-то напечатали. Проверять тип значения пока не нужно: задача решается одной строкой.

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

Метода toUpperCase у объединения нет и не будет — значит, нужно сначала получить значение, у которого он есть. Подходит любая функция, принимающая что угодно: String или общий для обоих вариантов toString.

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

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

Тип вместо строки

Функция раскрашивает уведомления. Тип параметра — string, поэтому опечатка в вызове проходит молча и уводит выполнение в запасную ветку. Опишите тип допустимых значений так, чтобы второй вызов стал ошибкой компилятора, а первый по-прежнему работал.

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

Три допустимых значения — это объединение трёх литеральных типов. Вынесем его в отдельное имя, чтобы тип можно было использовать и в других местах.

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

На исполнении по-прежнему напечатается красный и серый: диагностика запуск не отменяет. Зато опечатка теперь видна в редакторе, а не в проде. Последняя строка return "серый" после этого становится недостижимой — компилятор пока об этом молчит, но в статье про never мы заставим его и это заметить.

Скобки решают

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

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

Написанное означает «или строка, или массив чисел» — вертикальная черта связала string с number[], а не с number. Цепочка причин ведёт ровно туда: (string | number)[] не назначается number[], потому что string | number не назначается number, потому что string не назначается number. Лечится скобками.

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

Массив тут ни при чём: он с самого начала имел тип (string | number)[], компилятор вывел его сам. Неверной была аннотация — и это частый случай, когда тип, написанный руками, оказывается хуже выведенного.