Menu

Размеченные объединения в TypeScript (tagged unions)

Размеченное объединение это объединение объектных типов с общим литеральным свойством-тегом, например kind или status. Проверка тега сужает весь объект. Сам приём, сужение через switch, исчерпывающие проверки через never и как моделировать результаты API, состояние запроса и конечные автоматы.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

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

Внутри case "circle" чтение shape.width было бы ошибкой компиляции, потому что у члена-круга нет width. Тег это обычные данные: во время выполнения это просто строковое свойство, а switch это обычный JavaScript.

Что делает объединение размеченным

Сужение по тегу работает, когда выполняются три условия:

  1. Каждый член это объектный тип.
  2. У каждого члена есть свойство с одинаковым именем (дискриминант). Имя выбираете вы: часто используют kind, type, status и tag.
  3. В каждом члене это свойство имеет литеральный тип: строковый, числовой или логический литерал либо null/undefined.
type UiEvent =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; delta: number };

type ApiResponse =
  | { ok: true; data: string }    // boolean literal tags
  | { ok: false; error: string };

type Message =
  | { version: 1; text: string }  // number literal tags
  | { version: 2; text: string; lang: string };

Если один из членов объявляет тег как обычный string, а не литерал, сравнение тега перестаёт сужать объединение, и свойства конкретных членов остаются недоступны (TS2339).

Сужение по тегу

Любая понятная TypeScript проверка тега сужает объект: switch, if/else, === и !==, даже деструктурированный тег, если он объявлен как const:

function describe(e: UiEvent): string {
  if (e.type === "click") return `click at ${e.x},${e.y}`;

  const { type } = e;        // destructured tags narrow too
  if (type === "keypress") return `key ${e.key}`;

  return `scroll by ${e.delta}`; // only "scroll" is left
}

Проверка наличия свойства через "radius" in shape тоже сужает тип, но сравнение тега читается понятнее и делает возможной исчерпывающую проверку.

Исчерпывающие проверки

Главная польза приёма проявляется, когда объединение растёт. С явным типом возврата и без default TypeScript знает, что switch должен покрыть каждый тег, поэтому новый член без своего case это ошибка компиляции:

index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

Это сообщение не говорит, какой вариант пропущен. Вспомогательная функция assertNever говорит, а ещё выбрасывает исключение во время выполнения, если данные извне (JSON, старый клиент) несут тег, которого по типам быть не может:

Добавьте пятый статус без case, и строка assertNever(state) сообщит Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never', назвав член. Подробнее на странице never.

Невозможные состояния становятся невозможными

RequestState выше заменяет распространённую, более слабую структуру:

// Every combination is allowed, including nonsense
type LooseState<T> = {
  loading: boolean;
  data?: T;
  error?: string;
};

const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };

С необязательными полями «загрузка с данными и ошибкой» проходит проверку типов, и каждому читателю приходится гадать, какие сочетания реально возможны. С размеченным объединением data существует только в состоянии success, а error только в error, поэтому код, читающий state.data, должен сначала доказать, что он в состоянии success. Сам тип документирует допустимые состояния.

Моделирование результатов: успех или ошибка

Для «сработало или нет» достаточно логического тега. Этот тип Result это распространённая альтернатива выбрасыванию исключений при ожидаемых ошибках вроде неверного ввода:

Вызывающий код не может прочитать result.value, не проверив сначала result.ok, а именно эту проверку легко забыть с исключениями или с возвратом null.

Конечные автоматы и редьюсеры

Два размеченных объединения, одно для состояний и одно для действий, описывают конечный автомат. Редьюсер делает switch по действию и возвращает следующее состояние; компилятор проверяет, что каждый возвращаемый объект это допустимое состояние:

{ ...state, name: "paused" } компилируется только потому, что state сначала сужен до члена playing, и копия несёт track и position. Возврат одного { name: "paused" } был бы ошибкой: состоянию паузы нужен трек. Ту же структуру используют редьюсеры Redux и useReducer в React.

Ловушка расширения типа

Тег должен оставаться литеральным типом. У объекта, собранного в переменную без аннотации, тег расширяется до string, и тогда он не подходит ни под один член:

type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;

const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.

const ok1: Shape = { kind: "circle", radius: 2 };        // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const;      // or keep the literal with as const
area({ kind: "circle", radius: 2 });                     // or build it where Shape is expected

Правило, лежащее в основе (изменяемые свойства расширяются), объясняется на странице литеральные типы.

Часто задаваемые вопросы

Что такое размеченное объединение в TypeScript?

Объединение объектных типов, где у каждого члена есть одно и то же свойство (дискриминант, или тег) с разным литеральным типом, например { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. Проверка shape.kind === "circle" сужает shape до члена-круга, и его остальные свойства становятся доступны.

Чем объединение отличается от размеченного объединения?

Размеченное объединение это объединение с одним дополнительным правилом: у всех членов есть общее свойство с уникальным литеральным типом. Обычное объединение вроде Cat | Fish приходится сужать через in или собственные защитники типов; размеченное сужается сравнением одного свойства, а switch по этому свойству можно проверить на исчерпывающую обработку.

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

Либо задайте функции явный тип возврата и не пишите default (тогда пропущенный вариант это ошибка TS2366), либо добавьте default: return assertNever(value) с function assertNever(x: never): never { throw ... }. Вторая форма называет необработанный член в ошибке и ещё выбрасывает исключение во время выполнения, если придут неожиданные данные.

Почему моё размеченное объединение не сужается?

Тег должен быть литеральным типом в каждом члене. Если объект создан без аннотации, его kind расширяется до string, и он больше не подходит ни под один член (TS2345/TS2322). Исправьте это аннотацией, as const или созданием объекта там, где ожидается тип объединения. Член с типом kind: string тоже мешает объединению сужаться по kind.

Может ли дискриминант быть boolean или числом?

Да. Подходит любой литеральный тип: строки (kind: "circle"), числа (version: 2), логические значения (ok: true / ok: false) и даже null или undefined. Строковые теги встречаются чаще всего, потому что их удобно читать в логах и в JSON.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ