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

Размеченные объединения

Общий литеральный тег превращает объединение в размеченное: switch по тегу сужает каждую ветку, а default с assertNever делает добавление варианта ошибкой компиляции везде, где он не обработан.

TS Middle ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior

Тип результата вашего API — { loading: boolean; error?: string; data?: User }. Каждый компонент проверяет if (!loading && !error && data) и лезет в data — иногда data определён, хотя error тоже задан, иногда нет ни того, ни другого. Тип допускает все восемь комбинаций, но реальных состояний только три. Коллега выкатывает экран, читающий data.name во время ошибки, и он падает. Баг не в коде; он в типе. Фикс — сделать недопустимые состояния непредставимыми, и инструмент для этого — размеченное объединение.

Паттерн: общий литеральный тег

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

type Result =
  | { kind: "ok"; data: string }
  | { kind: "error"; message: string }
  | { kind: "loading" };

function render(r: Result): string {
  switch (r.kind) {
    case "ok":
      return r.data;
      //     ^? (parameter) r: { kind: "ok"; data: string }
    case "error":
      return r.message;
      //     ^? r: { kind: "error"; message: string }
    case "loading":
      return "…";
  }
}

В каждом case r сужен до единственного члена, чей kind совпадает с литералом, поэтому r.data доступен в "ok", а r.message — в "error", и больше нигде. Это заменяет восьмикомбинационную булеву кашу из Hook тремя взаимоисключающими, полностью типизированными состояниями. Недопустимые состояния (data при ошибке) теперь непредставимы.

Требования для работы сужения

Проверяющий распознаёт размеченное объединение, только когда:

  1. Дискриминант — литеральный тип, а не расширенный примитив. kind: "ok" работает; kind: string — нет. (Вспомните расширение let/const из урока про сужение — копирование тега в let это ломает.)
  2. Каждый член объявляет дискриминант с отдельным литералом. Если два члена делят kind: "ok", проверяющий не различит их и сольёт их поля.
  3. Вы ветвитесь по самому дискриминанту (r.kind), а не по производному значению.
// Сломано: расширенный дискриминант
type Bad = { kind: string; data: string } | { kind: string; err: string };
declare const b: Bad;
if (b.kind === "data") {
  b.data;
  // Error: Property 'data' does not exist on type 'Bad'.
  //   — kind это `string`, не литерал, поэтому сужения не происходит
}

Исчерпание через assertNever

Настоящая сила — ловить пропущенные случаи на компиляции. Когда каждый вариант обработан, значение в ветке default имеет тип never. Хелпер, принимающий только never, тогда вызывает ошибку компиляции в тот момент, когда добавлен новый вариант, но оставлен необработанным:

function assertNever(x: never): never {
  throw new Error("Unexpected variant: " + JSON.stringify(x));
}

function render(r: Result): string {
  switch (r.kind) {
    case "ok": return r.data;
    case "error": return r.message;
    case "loading": return "…";
    default: return assertNever(r);
    //                          ^? r: never  — сегодня всё в порядке
  }
}

Теперь добавьте четвёртый вариант { kind: "retrying"; attempt: number } в Result и не трогайте render. Ветка default больше не видит never — она видит { kind: "retrying"; attempt: number }:

// Error: Argument of type '{ kind: "retrying"; attempt: number; }'
//   is not assignable to parameter of type 'never'.

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

Почему это работает

Почему never, а не, скажем, default: throw? Голый throw срабатывает в рантайме; assertNever сдвигает сбой на компиляцию. Трюк в том, что never — пустой тип: только значение типа never присваиваемо параметру never. Пока объединение полностью обработано, суженный остаток — never, и вызов проходит проверку типов. В тот миг, когда объединение растёт, остаток становится реальным типом, уже не присваиваемым к never, и сборка ломается раньше, чем кто-либо запустит код. Из одной строки вы получаете и рантайм-защитник, и компиляционную гарантию.

Почему это лучше булевых флагов и in

Модель булевых флагов (loading, error, data независимы) допускает 2^n комбинаций, большинство из которых недопустимы. Модель in-защитника из прошлого урока работает, но хрупка при пересечении ключей. Тег-дискриминант однозначен, самодокументируем и — что важнее всего — проверяем на исчерпание. Это каноническая форма для: ответов API (ok/error), экшенов Redux/редьюсера (поле type), стейт-машин UI/сети (idle/loading/success/failure), узлов AST и токенов парсера.

// Экшен в стиле Redux — дискриминант по соглашению `type`
type Action =
  | { type: "increment"; by: number }
  | { type: "reset" };

function reduce(state: number, a: Action): number {
  switch (a.type) {
    case "increment": return state + a.by;
    case "reset": return 0;
    default: return assertNever(a);
  }
}
Расставь шаги по порядку

Упорядочи шаги, чтобы безопасно добавить новый вариант в проверяемое на исчерпание размеченное объединение.

  1. 1 Добавить новый вариант (с его литеральным тегом) в тип объединения
  2. 2 Перекомпилировать — каждый switch с default-assertNever теперь ошибается
  3. 3 Ветка default больше не сужается до never; она видит новый вариант
  4. 4 Добавить недостающий case в каждый помеченный switch
  5. 5 Перекомпилировать начисто — остаток снова never

Смоделируй стейт-машину дискриминантом

1/3
Викторина

Каков тип значения в ветке `default` исчерпывающего `switch` по полностью обработанному размеченному объединению?

Викторина

Вы добавляете новый вариант в размеченное объединение, но забываете обработать его в switch с default-`assertNever`. Что произойдёт?

Вспомните перед уходом
  1. 01
    Что делает объединение размеченным и каковы требования, чтобы проверяющий его сужал?
  2. 02
    Объясни паттерн исчерпания assertNever и почему он сдвигает сбой на компиляцию.
  3. 03
    Почему размеченное объединение лучше независимых булевых флагов или in-защитника для моделирования состояния?
Итог

Размеченные объединения — повседневный сеньорский паттерн, к которому ведёт весь этот юнит. Дав каждому члену объединения общий литеральный тег, вы позволяете проверяющему сужать весь объект чтением одного поля: в каждом case эксклюзивные поля варианта становятся доступны, а недопустимые комбинации — непредставимы — противоядие от каши булевых флагов. Соедините switch с default-assertNever(x: never), и исчерпание становится компиляционной гарантией: остаточный тип — never, пока обработан каждый вариант, а добавление варианта без обновления switch превращается в жёсткую ошибку сборки в каждом необработанном месте. Единственное правило, удерживающее это в работе: дискриминант обязан быть литералом на каждом члене, никогда не расширенным string. Далее типы доступа по индексу покажут, как выводить типы из этих объединений и из данных — Result["kind"], Action["type"] — чтобы ваши теги и типы значений оставались единым источником истины, а не дублировались вручную. Теперь, когда встречаешь { loading: boolean; error?: string; data?: User } в кодовой базе, знаешь, чем это заменить — и почему компилятор сам найдёт все места, которые пропустили при миграции.

Практика

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

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

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

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

Примени это

Примени этот урок в реальном проекте.

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

Trademarks belong to their respective owners. Editorial reference only.