Объединения: значение одного из нескольких типов
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Артикул приходит то числом, то строкой; ширина задаётся пикселями или словом auto. Для таких случаев в TypeScript есть объединение: запись string | number означает «одно из двух, а какое именно — на этой строке ещё неизвестно». Из этого «неизвестно» растёт главное правило статьи.
Объединение собирается вертикальной чертой: слева и справа от неё — типы, любой из которых допустим. Черта тут не «или-или» из логики, а перечисление: string | number — это множество значений, в котором есть все строки и все числа, и ничего больше.
Диагностика знакомая по главе про базовые типы: Тип "boolean" не может быть назначен для типа "string | number". Обратите внимание, как компилятор пишет само объединение — той же чертой, что и вы. Этот текст ещё не раз встретится в сообщениях, и читать его нужно как «список допустимого».
В параметрах функции объединение работает так же: внутрь пускают любой из вариантов и не пускают всё остальное.
Третий вызов — тот самый случай, ради которого типы и нужны. Компилятор сказал Аргумент типа "number[]" нельзя назначить параметру типа "string | number", а движок отработал молча и напечатал артикул: 1024: String([1024]) честно даёт "1024". Ошибка есть, а внешне всё в порядке — до того дня, когда в массиве окажется два элемента.
Писать объединение руками приходится не всегда: чаще компилятор собирает его сам, когда ветки кода возвращают разное. Это тот же вывод типов, что и в статье про наведение, просто результат теперь составной.
Объединение читается как «одно из», а не «и то и другое сразу». Значение всегда одного конкретного типа — просто в тексте программы не написано, какого именно.
Раз значение может оказаться любым из вариантов, компилятор разрешает с ним лишь то, что безопасно для каждого. Свойство, которое есть у строки, но отсутствует у числа, под запретом — иначе половина вызовов упала бы на исполнении.
Диагностика приходит с вложенной причиной, ровно как разбиралось в статье про чтение сообщений: Свойство "toUpperCase" не существует в типе "string | number", а строкой ниже — Свойство "toUpperCase" не существует в типе "number". Первая строка называет проблему, вторая — виновника: конкретный вариант, на котором обещание ломается.
А дальше пример показывает, зачем всё это затевалось. Первый вызов печатает A-1024, второй падает с TypeError: id.toUpperCase is not a function. Предупреждение прозвучало ещё до запуска и показывало пальцем ровно на числовой вариант — тот, что и сломался.
Что же тогда разрешено? Всё, что есть у каждого варианта. toString объявлен и у строки, и у числа — значит, он есть и у объединения, и такой код проходит без единой диагностики.
«Общее» считается по свойствам, а не по родству типов. Варианты могут быть сколь угодно разными: если нужное имя есть у обоих — доступ открыт, и тип результата тоже станет объединением.
Запуск, кстати, напечатает и ДА: передали-то мы строку, а у неё toUpperCase есть. Компилятор отвечает не за конкретный вызов, а за все возможные — и запрещает то, что сломается хотя бы на одном варианте.
С объектами правило то же самое, только выглядит нагляднее: доступны поля, объявленные во всех вариантах.
Вложенная причина здесь сообщает Свойство "bark" не существует в типе "Cat" — назван первый вариант, который не подошёл. И снова предупреждение сбылось буквально: Мурка напечаталась, а следующая строка дала TypeError: pet.bark is not a function.
Объединение — не объект, в котором есть все поля сразу. Это выбор из вариантов, и до проверки видна только их общая часть.
Если написать то же самое переменной, диагностика изменится — и это не опечатка компилятора.
Про переменную компилятор знает больше, чем про параметр: он видел присваивание и помнит, что там сейчас число. Объединения в сообщении уже нет — тип успел сузиться до одного варианта. У параметра такой истории нет: кто и что передаст, на этой строке неизвестно. Как именно работает это отслеживание, разбирают следующие статьи главы.
Нет — и по той же причине. Оператор + у строк и у чисел делает разное, а безопасного общего поведения у него нет.
Текст диагностики другой — Оператор "+" невозможно применить к типам "string | number" и "number", код TS2365. Оба вызова на исполнении что-то напечатали, но одно из этих «что-то» — склейка строк вместо сложения, тот самый сюжет из части I про преобразование типов.
Самое частое объединение в реальном коде состоит не из string и number, а из литеральных типов — тех самых, где «круг» был типом. Перечислили допустимые значения через черту — получили тип, в который больше ничего не влезет.
Черта перед первым вариантом — не опечатка, а привычное оформление длинных объединений: так строки выравниваются и любой вариант удаляется целой строкой. Компилятор к ведущей черте относится спокойно.
Ценность видна по выводу: движок напечатал статус: готово и глазом не моргнул. Ровно так эта опечатка и жила бы, будь параметр объявлен как string, — уехала бы в хранилище, в отчёт, в чужой код. Объединение литералов ловит её на сборке, а иногда компилятор ещё и подсказывает правильный вариант.
Здесь код не TS2345, а TS2820, и текст с продолжением: Тип ""готово"" невозможно присвоить типу "Status". Вы имели в виду ""готова""? Компилятор сравнил строку с вариантами, нашёл близкий и предложил замену. Отдельный код нужен как раз для того, чтобы редактор смог показать быстрое исправление.
Смешивать литералы с обычными типами тоже можно — так описывают значения вида «число или ключевое слово». В CSS такого добра много.
Объединение литералов — это список допустимых значений, записанный в типе. Тип string разрешает миллион строк, из которых верны три.
Объединение — множество побольше, каждый вариант — множество поменьше внутри него. Отсюда направление совместимости: string кладётся в string | number свободно, а обратно — нет. Обратное присваивание означало бы обещание, которое нечем подкрепить.
Вложенная причина опять называет виновника: Тип "number" не может быть назначен для типа "string". И опять диагностика сбывается — второй вызов печатает undefined, потому что у числа нет свойства length. Не падение, а тихая чепуха в логах: такие ошибки ищут дольше всего.
| Что делаем | Что скажет компилятор |
|---|---|
Кладём string в string | number | молчит — вариант из списка |
Кладём string | number в string | TS2322 |
Передаём number в параметр string | number | молчит |
Передаём string | number в параметр string | TS2345 |
| Читаем свойство, которое есть у всех вариантов | молчит |
| Читаем свойство, которого нет хотя бы у одного | TS2339 |
Отдельная ловушка — скобки. Запись (string | number)[] и запись string[] | number[] выглядят похоже, а означают разное: первая — один массив со смешанным содержимым, вторая — выбор между двумя однородными массивами.
Сообщение Аргумент типа ""A-3"" нельзя назначить параметру типа "never" сначала обескураживает. Логика такая: push у массива строк принимает строки, у массива чисел — числа, а какой из двух перед нами, неизвестно. Значит, добавить можно лишь то, что подходит обоим сразу, — а таких значений не существует. Пустой набор компилятор и называет never; отдельная статья главы посвящена ему целиком.
Скобки в типах значат ровно то же, что в арифметике. (string | number)[] — массив из «одного из», string[] | number[] — «один из» массивов.
Ограничение «только общее» не тупик, а требование определённости: хотите методы строки — сначала покажите, что перед вами строка. Доказательством служит обычная проверка, которая работала бы и в чистом JavaScript.
Ни одной диагностики, хотя обе строки по отдельности запрещены. Компилятор проследил поток выполнения и понял, в какую ветку какие значения попадают. Приём называется сужением типа, и ему посвящена почти вся эта глава — от typeof до собственных проверок.
Второй честный выход — не выбирать вариант, а привести значение к общему знаменателю. Часто это проще проверки: функции вроде String принимают что угодно.
А есть выход ложный, и встретите вы его в чужом коде обязательно. Оператор as ничего не проверяет — он утверждает. Компилятор соглашается смотреть на значение как на строку, потому что строка входит в объединение, и замолкает.
Диагностик ноль, а второй вызов врёт. В отличие от примера с 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. Почините так, чтобы диагностик не осталось и оба вызова что-то напечатали. Проверять тип значения пока не нужно: задача решается одной строкой.
Метода toUpperCase у объединения нет и не будет — значит, нужно сначала получить значение, у которого он есть. Подходит любая функция, принимающая что угодно: String или общий для обоих вариантов toString.
Вариант с проверкой typeof тоже верный и в других задачах будет единственным возможным — но здесь он длиннее без всякой пользы: обе ветки делали бы одно и то же. Приведение к общему знаменателю дешевле проверки, когда варианты обрабатываются одинаково.
Тип вместо строки
Функция раскрашивает уведомления. Тип параметра — string, поэтому опечатка в вызове проходит молча и уводит выполнение в запасную ветку. Опишите тип допустимых значений так, чтобы второй вызов стал ошибкой компилятора, а первый по-прежнему работал.
Три допустимых значения — это объединение трёх литеральных типов. Вынесем его в отдельное имя, чтобы тип можно было использовать и в других местах.
На исполнении по-прежнему напечатается красный и серый: диагностика запуск не отменяет. Зато опечатка теперь видна в редакторе, а не в проде. Последняя строка return "серый" после этого становится недостижимой — компилятор пока об этом молчит, но в статье про never мы заставим его и это заметить.
Скобки решают
В корзине лежат и артикулы-строки, и артикулы-числа. Тип написан почти правильно, но компилятор выдаёт TS2322 с длинной цепочкой причин. Прочитайте цепочку до конца и исправьте тип — менять массив нельзя.
Написанное означает «или строка, или массив чисел» — вертикальная черта связала string с number[], а не с number. Цепочка причин ведёт ровно туда: (string | number)[] не назначается number[], потому что string | number не назначается number, потому что string не назначается number. Лечится скобками.
Массив тут ни при чём: он с самого начала имел тип (string | number)[], компилятор вывел его сам. Неверной была аннотация — и это частый случай, когда тип, написанный руками, оказывается хуже выведенного.