Union и discriminated union: моделирование вариантов без невозможных сочетаний
Автор: Казачкин Даниил Михайлович · Обновлено
Объединение типов описывает значение, которое подходит хотя бы одному из вариантов. Для состояния задачи важна не только допустимость отдельных полей, но и допустимость их…
Объединение типов описывает значение, которое подходит хотя бы одному из вариантов. Для состояния задачи важна не только допустимость отдельных полей, но и допустимость их сочетаний: результат появляется после завершения, а сообщение об ошибке относится к неудачному исходу.
Цель и необходимые знания
Предполагается понимание строковых литералов, объектов и сужения по условию. Вы построите модель задания на экспорт, исключите противоречивые состояния и проверите полноту обработки при добавлении нового варианта. Здесь рассматривается статическая модель; отдельный практический урок показывает, как проверять такое сообщение на входе через Zod.
Почему набор необязательных полей мешает
Начальная модель часто содержит status, fileName?, progress? и error?. Она удобна при первом наброске, но разрешает странные объекты: завершённую задачу без файла, ожидающую задачу с ошибкой и одновременно результат с сообщением о неудаче. Каждый компонент вынужден заново угадывать, что означает такая комбинация. Проблема находится не в отсутствии очередного if, а в слишком слабом описании разрешённых состояний.
Дискриминированное объединение связывает конкретное значение общего поля с отдельной формой объекта. У каждого варианта свой набор обязательных данных. Проверка state.status позволяет сузить весь объект и получить доступ именно к тем полям, которые гарантированы соответствующим состоянием.
export {};
type ExportState =
| { status: 'queued'; position: number }
| { status: 'running'; completed: number; total: number }
| { status: 'done'; fileName: string }
| { status: 'failed'; message: string };
function assertUnreachable(value: never): never {
throw new Error('Необработанное состояние: ' + JSON.stringify(value));
}
function describeExport(state: ExportState): string {
switch (state.status) {
case 'queued': return 'Позиция ' + state.position;
case 'running': return state.completed + '/' + state.total;
case 'done': return 'Готов файл ' + state.fileName;
case 'failed': return 'Ошибка: ' + state.message;
default: return assertUnreachable(state);
}
}
console.log(describeExport({ status: 'queued', position: 3 }));
console.log(describeExport({ status: 'done', fileName: 'lessons.csv' }));
if (false) {
// @ts-expect-error Завершённой задаче требуется имя результата.
const incomplete: ExportState = { status: 'done' };
void incomplete;
}Пример выводит Позиция 3 и Готов файл lessons.csv. В ветке done поле fileName обязательно, поэтому не нужна запасная строка для ситуации, которую договорённость запрещает. При этом числовые ограничения position, completed и total ещё не проверены: тип number не задаёт положительность и не доказывает, что выполнено не больше общего количества.
Объединение не является слиянием объектов
В типе A | B доступны без дополнительной проверки только операции, допустимые для всех оставшихся вариантов. Нельзя сразу прочитать fileName, если значение может быть ожидающей задачей. Пересечение A & B, напротив, требует соответствовать обеим формам. Это принципиально иной договор, а не сокращённая запись объединения полей нескольких возможных состояний.
Повтор одинакового примитивного варианта в union не создаёт новый случай: 'queued' | 'done' | 'done' допускает те же значения, что 'queued' | 'done'. Поэтому отдельные уроки под «union», «объединённые типы» и «union types» не нужны: это одна тема. Но объектные ветви следует проектировать осмысленно. Наличие нескольких совместимых объектных форм не превращает union автоматически в точный взаимоисключающий формат внешнего документа.
Дискриминатор — часть протокола
Хорошее поле различия стабильно и имеет ограниченный набор литеральных значений. Его должны одинаково понимать создающая и принимающая стороны. Если назвать поле type или kind, поведение TypeScript не изменится; важна связь значений с формами. Не стоит определять вариант по случайному наличию необязательного поля, если протокол может естественно иметь явную метку состояния.
Полезно возвращать новое состояние как законченный объект, а не менять поля общего объекта по одному. Промежуточная операция «поставить done, потом когда-нибудь добавить файл» создаёт период некорректности, который трудно описать. Конструирование { status: 'done', fileName } выражает атомарный результат перехода на уровне обычного кода. Ограничение допустимых переходов между состояниями при необходимости проверяется отдельной функцией.
Как заметить забытый вариант
Добавьте состояние { status: 'cancelled'; reason: string } в основной тип. Вызов assertUnreachable(state) теперь выдаст ошибку: компилятор видит оставшийся вариант. Именно поэтому запасная ветка с произвольным текстом менее полезна для контролируемого конечного набора. Она может скрыть изменение модели, которое нужно обдумать во всех потребителях.
Внешний сервер всё равно способен прислать неизвестную метку. Такое сообщение следует сначала распознать на входе и превратить в известный результат проверки. Не пытайтесь одновременно объявить статический набор полностью исчерпывающим и молча считать допустимыми любые неизвестные строки: у этих решений разные гарантии. Модель внутри приложения может быть строгой, а адаптер границы — явно обрабатывать несовместимый формат.
Типичные ошибки
Не объявляйте дискриминатор просто как string во всех ветвях: компилятор потеряет конечную связь. Не разносите связанные поля по независимым параметрам без необходимости: между status и случайным fileName уже может не быть выраженной зависимости. Не используйте принудительное утверждение для создания неготовой ветви. Если файл ещё загружается, это отдельное состояние, а не ложное обещание завершения.
Практикум: отмена и прогресс экспорта
Дополните модель отменой с обязательной причиной и обновите describeExport. Затем напишите создание состояния running с проверкой: общее количество должно быть положительным целым, выполненное — целым от нуля до общего включительно. Проверьте пары (0, 5), (5, 5), (6, 5) и (1, 0). Первые две допустимы, последние должны быть отвергнуты вашим обычным кодом проверки.
Разбор: новая ветка делает добавление отмены видимым в функции описания; never помогает найти забытое место. Проверка чисел решает другую задачу и выполняется до создания состояния. Не следует автоматически заменять running на done при равных числах: результат ещё может сохраняться на диск. Если такое поведение нужно продукту, переход должен дождаться фактического завершения операции и получения имени файла.
Частые вопросы
Почему union двух объектов разрешает только общие свойства?
До сужения компилятор не знает, какой вариант получен, поэтому любая непосредственная операция должна быть безопасна для каждого возможного случая. Проверка дискриминатора уменьшает набор вариантов и открывает свойства конкретной ветви, не изменяя сам объект.
Дискриминированный union уже проверяет ответ сервера?
Нет. Он описывает тип известного сообщения внутри программы. Чтобы передать произвольный JSON в такую модель, необходимо распознать метку, проверить обязательные поля выбранной ветви и решить, что делать с лишними либо неизвестными данными. Этому посвящён практический кейс Zod.
Связанные исследования
- Типы как множества значений: полезная модель и её границы в TypeScript — Проверяем модель типов как множеств значений: union, intersection, unknown и never; контрпримеры с any, распределением условных типов и мутацией общих ссылок.