У теорії типів дискриміновані об'єднання (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' |
+-------------------------------------------------------------------------+assertNever та вичерпного зіставлення#Функція 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);
}
}Для створення декларативних конвеєрів обробки даних створимо функцію вищого порядку matchStatus:
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);
}
},
};Якщо оголосити поле дискримінатора із загальним примітивним типом 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); // Компілятор мовчить, баг пропущено в прод!Визначте всі допустимі фази сутності та усуньте прапорці-дублери (isLoading, isError, hasData).
Задайте єдину рядкову або числову літеральну властивість (status / type / kind) для кожного інтерфейсу об'єднання.
Впровадьте функцію assertNever(x: never): never у базовий шар утиліт проєкту.
Замініть розрізнені перевірки if-else на switch із секцією default, що викликає assertNever.
Додайте в union тестовий тип-заглушку та переконайтеся, що CI/CD та tsc блокують збірку до оновлення всіх обробників.