Размеченные объединения: поле-метка вместо догадок

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

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

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

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

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

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

TS18048, «Возможно, result.data имеет значение undefined». Претензия справедлива: data помечено вопросительным знаком, значит, его может не быть, — и проверка result.ok компилятора в этом не разубедит. Между ok и data в типе нет ни малейшей связи, это просто два независимых необязательных поля.

Диагностику мы, как обычно, проигнорировали, и второй вызов показал, чем это кончается: TypeError ровно на той строке, куда указывал компилятор. Причём объект { ok: true } прошёл проверку типов без единого замечания — по типу он совершенно законный.

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

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

Ни звука. «Успех вместе с ошибкой» и «ответ вообще ни о чём» — полноправные значения типа Result, хотя ваша программа ни того, ни другого выдать не может. Тип получился шире реальности, а всё, что в него лишнего влезло, приходится отсеивать руками при каждом обращении к полю.

Необязательное поле обещает «может быть, а может и нет». Если на самом деле «есть всегда, когда заполнено соседнее поле» — типом это не записано.

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

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

Диагностик нет ни одной, а TypeError остался на прежнем месте. ! ничего не проверяет — он снимает вопрос у компилятора и оставляет ответственность вам. Дальше в статье этот вопрос отпадёт сам: спрашивать будет не о чем.

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

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

Ни одной диагностики и ни одного вопросительного знака. Сравнение result.status === "ok" компилятор читает так же, как читал typeof в статье про сужение по typeof и по истинности: в первом варианте status не может быть ничем, кроме строки "ok", во втором — ничем, кроме "error", значит, внутри ветки остаётся ровно один вариант — целиком, со всеми своими полями.

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

Выигрыш не только на чтении. Собрать неправильный ответ теперь тоже не выйдет.

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

Три вызова — три диагностики, и в каждой компилятор называет конкретный вариант: «"message" не существует в типе "{ status: "ok"; data: string; }"», «Свойство "data" отсутствует ... и является обязательным». Он не перебирает варианты наугад — он выбрал нужный по метке, которую вы сами написали, и предъявляет претензии именно к нему.

Отсюда самое частое применение приёма: функция, которая может не справиться, возвращает не исключение и не null, а один из вариантов.

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

Последняя строка — та самая забытая проверка: TS2339 и вложенная причина «"message" не существует в типе "{ status: "ok"; value: number; }"». На исполнении сообщение всё-таки напечаталось — в этом конкретном вызове там и правда была ошибка. Компилятор возражает не против одного удачного вызова, а против кода, который сработает не всегда.

Имя метки компилятору безразлично: kind, type, status, tag — годится любое, лишь бы одно и то же во всех вариантах.

Самая частая поломка в этой теме выглядит как мелочь в объявлении типа: вместо литерала написан string.

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

TS2339 — та же ошибка, что была бы вообще без проверки. И это честно: если status объявлен как string, то строку "ok" в нём может содержать любой из вариантов, сравнение не отсекает ни одного. Литеральные типы здесь не украшение, а весь механизм целиком: без них сужать нечего.

Тип поля-меткиРазличает вариантыКогда так пишут
"ok", "error" — строковые литералыдапочти всегда: имя варианта видно и в коде, и в отладчике
1, 2 — числовые литералыдаверсии формата данных
true и falseдакогда исходов ровно два
nullдаредко, но это тоже единственное значение
string, number, booleanнетпо недосмотру — и сужение молча перестаёт работать

Логическая метка встречается часто и выглядит короче всех остальных.

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

answer.ok здесь не просто флаг: в истинной ветке тернарного оператора остаётся первый вариант со своим value, в ложной — второй со своим reason. То же сужение, только вместо сравнения работает сама истинность.

Вторая поломка коварнее: тип написан правильно, а значение до него не доезжает.

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

Матрёшка диагностики доводит до сути: «Тип "string" не может быть назначен для типа ""circle""». У объектного литерала поля изменяемые, поэтому строка в kind выводится как string, а не как литерал. Это то самое расширение типа, о котором шла речь в статье про readonly и as const.

Лечится двумя знакомыми способами: назвать тип при объявлении переменной или заморозить литерал через as const.

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

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

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

wanted объявлена через const, её тип — литерал "ok", и сужение проходит. У alsoWanted тип раскрылся до string, и второе сравнение не даёт ничего: TS2339. На исполнении обе строки печатают одно и то же — разница целиком в типах.

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

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

Обратите внимание, чего в этой функции нет: return после switch. Объявлено, что area возвращает number, и обычно компилятор требует возврата на всех путях — но здесь он видит, что ветки покрыли все три варианта, и дойти до конца функции невозможно.

Из этого получается самая дешёвая страховка в языке. Допустим, через месяц в тип добавили четвёртый вид фигуры, а про функцию забыли.

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

TS2366: «В функции отсутствует завершающий оператор return, а тип возвращаемого значения не включает "undefined"». Компилятор не говорит «вы забыли треугольник» — он говорит «есть путь, на котором функция ничего не вернёт», а появился этот путь ровно из-за пропущенного варианта. Правка одного типа сама привела к списку мест, которые надо дописать. Более разговорчивый способ сказать то же самое — через never, ему посвящена следующая статья.

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

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

В switch по метке default чаще вреден, чем полезен: он гасит единственный сигнал о том, что вариант забыли.

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

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

Прочитать items, не доказав, что загрузка закончилась, физически негде: в других вариантах такого поля нет. А последняя строка показывает обратную сторону — TS2353 на попытку прицепить список к состоянию "loading". Данных, которых ещё нет, теперь нельзя даже создать.

Сравните с привычным набором флагов: isLoading, isDone, error, items. Комбинаций там шестнадцать, осмысленных — четыре, и каждая функция, которая это читает, обречена гадать. Правило перевода простое: поля, которые всегда появляются и исчезают вместе, — это один вариант; поля, которые не бывают заполнены одновременно, — это разные варианты. В части IV то же правило встретится в React: в разделе «Флаги, которые противоречат друг другу» несколько булевых флагов состояния сводят в один статус.

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

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

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

Метка не обязана лежать в самом объекте: сужение проходит и по пути из нескольких полей — компилятор запоминает, что проверяли именно message.body.kind.

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

И сразу о пределах. Сужение — это вывод, сделанный один раз в точке проверки, и следит компилятор только за той переменной, которую видит.

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

Ни одной диагностики, а в консоли undefined. Колбэк заменил body через другую ссылку на тот же объект, и к моменту чтения метка там уже "photo". Присваивание в саму переменную компилятор отследил бы, изменение через чужую ссылку — нет: то самое свойство ссылок, что разбиралось в части I.

Осталось честно назвать границу приёма. Метка не появляется у данных сама: разобранный JSON из сети — это unknown, и объявление типа его размеченным объединением не делает. Проверять метку у чужих данных придётся руками, и об этом — статья «Данные извне — это unknown» из главы про границу программы.

Хорошее правило на каждый день: увидели в типе больше одного необязательного поля — спросите себя, не два ли это разных типа.

  • Размеченное объединение — это несколько типов-вариантов и общее поле-метка с литеральным типом; проверка метки сужает объединение до одного варианта целиком, со всеми его полями.
  • Метка обязана быть в каждом варианте, называться одинаково и быть литеральной: string вместо "ok" убивает сужение и не вызывает ни одной жалобы.
  • У объектного литерала в const метка расширяется до string — помогает объявленный тип переменной или as const.
  • switch по метке с объявленным типом возврата ловит забытый вариант через TS2366; ветка default эту страховку выключает.
  • Набор необязательных полей описывает больше состояний, чем бывает в жизни; варианты описывают ровно те, что бывают, — невозможное просто не собирается.
  • Данные из сети размеченным объединением не становятся сами: у чужого unknown метку проверяют руками на границе программы.

1/4В объединении двух объектных типов поле-метка объявлено как status: string, а не литералом. Внутри if (result.status === "ok") читают result.data. Что скажет компилятор?

2/4Написано const raw = { kind: "circle", radius: 2 };, и это значение передают в функцию, которая ждёт размеченное объединение Shape. Что произойдёт?

3/4Функция с объявленным типом возврата number раскладывает объединение из четырёх вариантов в switch, но веток только три и default нет. Что скажет компилятор?

4/4Ответ описан одним типом, где все поля необязательные. На result.data.toUpperCase() компилятор выдал TS18048, но пример всё равно запустили — с аргументом { ok: true }. Что окажется в консоли?

Из мешка — в объединение

Уведомление бывает двух видов: всплывающее — текст и сколько секунд висит, и ссылочное — текст и адрес. Сейчас оба описаны одним типом с необязательными полями, поэтому вызов с забытым seconds проходит молча и печатает undefined. Перепишите тип размеченным объединением с меткой kind, не трогая тело render.

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

Два вида — два типа. Метка kind есть в обоих и типизирована литералом, а поля перестают быть необязательными: в своём варианте каждое из них обязательно.

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

Тело функции не изменилось ни на символ, но смысл у него другой: notice.seconds в первой ветке — это number, а не «может быть, число». Последняя строка теперь даёт TS2345: «Свойство "seconds" отсутствует в типе "{ kind: "toast"; text: string; }" и является обязательным в типе "{ kind: "toast"; text: string; seconds: number; }"».

Метка, которая не сработала

Тип написан правильно, функция тоже, а вызов не проходит: TS2345, и в глубине причин — «Тип "string" не может быть назначен для типа ""circle""». Числа и структуру объекта менять нельзя. Почините двумя разными способами.

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

Значение правильное, а тип у него слишком широкий: kind вывелся как string, потому что поле объекта изменяемо. Нужно вернуть метке литеральный тип — либо объявив тип переменной, либо запретив изменения через as const.

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

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

Страховка, которая всё испортила

В типе четыре фигуры, в switch — две ветки и default «на всякий случай». Прямоугольник и треугольник тихо считаются нулевыми, компилятор молчит. Сделайте так, чтобы о пропущенных вариантах сказал он, а не пользователь, и допишите недостающее.

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

Сначала убираем default. Компилятор тут же отвечает TS2366 — «В функции отсутствует завершающий оператор return»: раз ветки покрывают не все варианты, есть путь, на котором функция не вернёт ничего. Дальше остаётся дописать ветки, пока диагностика не исчезнет.

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

Диагностика исчезла — значит, разобраны все варианты. Именно поэтому default в таком switch и не пишут: он превращает будущий забытый вариант в тихий нуль вместо ошибки на сборке.