В теории типов дискриминированные объединения (Discriminated Unions, также известные как Tagged Unions, Algebraic Data Types или Sum Types) представляют собой структуру данных, способную принимать одно из нескольких строго фиксированных состояний. Каждое состояние обладает общим литеральным свойством-меткой (дискриминатором), по которому анализатор потока управления (Control Flow Analysis, CFA) TypeScript детерминированно вычисляет конкретный тип ветки.
В enterprise-системах дискриминированные объединения являются фундаментальным средством предотвращения невалидных состояний программы на этапе компиляции (Make Impossible States Impossible). Классический антипаттерн «мешок необязательных полей с булевыми флагами» приводит к комбинаторному взрыву потенциально ошибочных конфигураций памяти:
+-------------------------------------------------------------------------+
| СРАВНЕНИЕ МОДЕЛЕЙ СОСТОЯНИЯ В ПАМЯТИ |
+-------------------------------------------------------------------------+
| [Антипаттерн: Плоский объект с флагами] |
| interface BadState { |
| isLoading: boolean; |
| isError: boolean; |
| data?: UserData; |
| error?: Error; |
| } |
| Потенциальных состояний: 2^2 * 2 * 2 = 16 комбинаций |
| (Включая абсурдные: isLoading=true И isError=true И data!=null) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [Дискриминированное объединение: Строгий конечный автомат] |
| type RemoteData<T, E> = |
| | { status: 'idle' } |
| | { status: 'loading' } |
| | { status: 'success'; data: T } |
| | { status: 'error'; error: E }; |
| Ровно 4 взаимоисключающих состояния. Невалидные состояния невыразимы! |
+-------------------------------------------------------------------------+Механизм исчерпывающей проверки (Exhaustive Checking) базируется на семантике типа never. В TypeScript тип never является пустым множеством (Bottom Type). Ни одно значение, кроме самого never, не может быть присвоено переменной этого типа.
Если анализатор CFA прошел через все ветки case дискриминированного объединения, в блоке default тип переменной сужается до never. Если передать эту переменную в функцию assertNever(x: never): never, компиляция пройдет успешно. Но как только инженер добавит в объединение новый вариант и забудет обновить ветвление, тип остатка станет равен этому новому варианту, и компилятор выбросит ошибку несоответствия типов.
+-------------------------------------------------------------------------+
| КОНВЕЙЕР СУЖЕНИЯ ТИПОВ В SWITCH |
+-------------------------------------------------------------------------+
| Вход: action: PaymentAction (Card | Crypto | BankWire) |
| | |
| |-- case 'card': action сужен до CardPayment |
| |-- case 'crypto': action сужен до CryptoPayment |
| |-- case 'bank_wire': action сужен до BankWirePayment |
| | |
| `-- default: |
| Остаток типов: never (все ветки исчерпаны) |
| assertNever(action) -> Компиляция успешна! |
+-------------------------------------------------------------------------+
|
(Добавлен новый вариант: 'apple_pay')
v
+-------------------------------------------------------------------------+
| default: |
| Остаток типов: ApplePayPayment |
| assertNever(action) |
| ОШИБКА: Argument of type 'ApplePayPayment' is not assignable to 'never'|
+-------------------------------------------------------------------------+Определите все допустимые фазы сущности и устраните флаги-дублеры (isLoading, isError, hasData).
Задайте единое строковое или числовое литеральное свойство (status / type / kind) для каждого интерфейса объединения.
Внедрите функцию assertNever(x: never): never в базовый слой утилит проекта.
Замените разрозненные проверки if-else на switch с секцией default, вызывающей assertNever.
Добавьте в union тестовый тип-заглушку и убедитесь, что CI/CD и tsc блокируют сборку до обновления всех обработчиков.
Какое условие является обязательным для того, чтобы объединение типов считалось дискриминированным (Discriminated Union)?
Функция assertNever принимает аргумент типа never и генерирует исключение в рантайме, если код каким-либо образом выполнился (например, из-за небезопасного приведения типов или некорректных данных внешнего API):
/**
* Гарантирует исчерпывающую проверку вариантов объединения на этапе компиляции.
* Выбрасывает исключение в рантайме при нарушении инварианта типизации.
*/
export function assertNever(value: never, customMessage?: string): never {
throw new Error(
customMessage ??
`[ExhaustiveCheckError] Необработанный вариант объединения: ${JSON.stringify(value)}`
);
}Рассмотрим промышленную реализацию состояний заказа в E-commerce платформе. Каждое состояние содержит только релевантные поля:
import { assertNever } from '../utils/assertNever';
export interface OrderDraft {
readonly status: 'draft';
readonly createdAt: Date;
readonly items: ReadonlyArray<{ itemId: string; quantity: number }>;
}
export interface OrderPendingPayment {
readonly status: 'pending_payment';
readonly orderId: string;
readonly amountCents: number;
readonly paymentGatewayUrl: string;
readonly expiresAt: Date;
}
export interface OrderPaid {
readonly status: 'paid';
readonly orderId: string;
readonly transactionId: string;
readonly paidAt: Date;
}
export interface OrderCancelled {
readonly status: 'cancelled';
readonly orderId: string;
readonly reason: 'timeout' | 'user_rejected' | 'fraud_detected';
readonly cancelledAt: Date;
}
export interface OrderFulfilled {
readonly status: 'fulfilled';
readonly orderId: string;
readonly trackingNumber: string;
readonly deliveredAt: Date;
}
// Корневое дискриминированное объединение
export type OrderState =
| OrderDraft
| OrderPendingPayment
| OrderPaid
| OrderCancelled
| OrderFulfilled;
/**
* Чистая функция форматирования статуса для отображения в пользовательском интерфейсе.
* Компилятор гарантирует обработку всех 5 вариантов.
*/
export function formatOrderStatusMessage(state: OrderState): string {
switch (state.status) {
case 'draft':
return `Черновик заказа от ${state.createdAt.toLocaleDateString()}`;
case 'pending_payment':
return `Ожидает оплаты до ${state.expiresAt.toLocaleTimeString()}. Ссылка: ${state.paymentGatewayUrl}`;
case 'paid':
return `Оплачено (Транзакция: ${state.transactionId})`;
case 'cancelled':
return `Заказ аннулирован. Причина: ${state.reason}`;
case 'fulfilled':
return `Доставлено. Трек-номер: ${state.trackingNumber}`;
default:
// При добавлении нового статуса (например, 'refunded') компилятор укажет на ошибку здесь
return assertNever(state);
}
}Для создания декларативных конвейеров обработки данных создается функция высшего порядка match:
import { assertNever } from './assertNever';
type MatcherTable<U extends { status: string }, R> = {
[K in U['status']]: (variant: Extract<U, { status: K }>) => R;
};
/**
* Декларативный pattern matching для дискриминированных объединений по полю 'status'.
*/
export function matchStatus<U extends { status: string }, R>(
value: U,
table: MatcherTable<U, R>
): R {
const handler = table[value.status as U['status']];
if (!handler) {
return assertNever(value as never, `Не найден обработчик для статуса: ${value.status}`);
}
return handler(value as Extract<U, { status: typeof value.status }>);
}Определение набора интерфейсов с общим строковым литеральным ключом (status). Создание объединенного типа OrderState.
Компилятор парсит блок switch/case. В каждой ветке тип сужается операцией сужения типов (Narrowing) по литералу дискриминатора.
В секции default компилятор вычисляет разность множеств: T_initial \ (A | B | C). Если разность равна пустому множеству, получается тип never.
Если тип в default равен never, assertNever проходит компиляцию без ошибок. При наличии необработанного типа генерируется ошибка TS2345.
import { assertNever } from '../utils/assertNever';
export type RemoteData<TData, TError = Error> =
| { readonly status: 'idle' }
| { readonly status: 'loading' }
| { readonly status: 'success'; readonly data: TData }
| { readonly status: 'failure'; readonly error: TError };
export const RemoteData = {
idle: <T, E = Error>(): RemoteData<T, E> => ({ status: 'idle' }),
loading: <T, E = Error>(): RemoteData<T, E> => ({ status: 'loading' }),
success: <T, E = Error>(data: T): RemoteData<T, E> => ({ status: 'success', data }),
failure: <T, E = Error>(error: E): RemoteData<T, E> => ({ status: 'failure', error }),
isIdle: <T, E>(rd: RemoteData<T, E>): rd is { readonly status: 'idle' } =>
rd.status === 'idle',
isLoading: <T, E>(rd: RemoteData<T, E>): rd is { readonly status: 'loading' } =>
rd.status === 'loading',
isSuccess: <T, E>(rd: RemoteData<T, E>): rd is { readonly status: 'success'; readonly data: T } =>
rd.status === 'success',
isFailure: <T, E>(rd: RemoteData<T, E>): rd is { readonly status: 'failure'; readonly error: E } =>
rd.status === 'failure',
/**
* Исчерпывающий обработчик состояний с гарантией обработки всех веток
*/
fold: <T, E, R>(
rd: RemoteData<T, E>,
matchers: {
onIdle: () => R;
onLoading: () => R;
onSuccess: (data: T) => R;
onFailure: (error: E) => R;
}
): R => {
switch (rd.status) {
case 'idle':
return matchers.onIdle();
case 'loading':
return matchers.onLoading();
case 'success':
return matchers.onSuccess(rd.data);
case 'failure':
return matchers.onFailure(rd.error);
default:
return assertNever(rd);
}
},
};Что произойдет при компиляции функции с конструкцией default: assertNever(action), если разработчик добавит в объединение новый тип NewAction, но забудет написать для него отдельный case?
Если объявить поле дискриминатора с общим примитивным типом string вместо строковых литералов, компилятор не сможет выполнить сужение типов, и ветвление не устранит неопределенность.
// ❌ АНТИПАТТЕРН: status имеет общий тип string
interface StateA { status: string; count: number; }
interface StateB { status: string; text: string; }
type MyState = StateA | StateB;
function handle(s: MyState) {
if (s.status === 'a') {
// ОШИБКА: s не сузился до StateA! Доступ к s.count вызовет ошибку TS2339
console.log(s.count);
}
}
// ✅ ПРАВИЛЬНО: Строгие литеральные типы
interface StateAGood { status: 'a'; count: number; }
interface StateBGood { status: 'b'; text: string; }
type MyStateGood = StateAGood | StateBGood;Использование простого break или пустого default: скрывает ошибки при добавлении новых вариантов объединения. Код компилируется без предупреждений, но новый кейс приводит к тихому выпадению в рантайме.
// ❌ АНТИПАТТЕРН: Тихий пропуск необработанного статуса
function processBad(state: OrderState) {
switch (state.status) {
case 'draft': return;
case 'paid': return;
// Забыли pending_payment, cancelled, fulfilled!
default:
break; // Ошибки компиляции нет, но логика сломана!
}
}
// ✅ ПРАВИЛЬНО: assertNever заставляет компилятор выбросить ошибку
function processGood(state: OrderState) {
switch (state.status) {
case 'draft': return;
case 'pending_payment': return;
case 'paid': return;
case 'cancelled': return;
case 'fulfilled': return;
default:
return assertNever(state); // Ошибка на этапе сборки при пропуске!
}
}Если в одном объединении один тип имеет ключ type: 'A', а другой kind: 'B', TypeScript не сможет использовать единую точку входа для сужения через switch (item.type).
// ❌ АНТИПАТТЕРН: Разные имена полей-меток
type Event =
| { type: 'click'; x: number }
| { kind: 'hover'; y: number }; // Разные ключи!
// ✅ ПРАВИЛЬНО: Единое имя дискриминатора для всего объединения
type DomainEvent =
| { type: 'click'; x: number }
| { type: 'hover'; y: number };Использование небезопасного приведения assertNever(state as any) полностью отключает проверку типов компилятором, превращая инструмент статической защиты в бесполезную формальность.
// ❌ АНТИПАТТЕРН: Обход компилятора через any
default:
return assertNever(state as any); // Компилятор молчит, баг пропущен в прод!