Размеченное объединение (его также называют tagged union) это объединение объектных типов, у которых есть одно общее свойство, тег, с разным литеральным значением в каждом члене. Проверка тега сообщает TypeScript, какой член перед вами, и остальные свойства этого члена становятся доступны.
Внутри case "circle" чтение shape.width было бы ошибкой компиляции, потому что у члена-круга нет width. Тег это обычные данные: во время выполнения это просто строковое свойство, а switch это обычный JavaScript.
Что делает объединение размеченным
Сужение по тегу работает, когда выполняются три условия:
- Каждый член это объектный тип.
- У каждого члена есть свойство с одинаковым именем (дискриминант). Имя выбираете вы: часто используют
kind,type,statusиtag. - В каждом члене это свойство имеет литеральный тип: строковый, числовой или логический литерал либо
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.