У теорії типів об'єднання (Union, A | B) та перетин (Intersection, A & B) є базовими операціями над множинами значень. Значення типу A | B належить або множині A, або множині B, або обом одночасно. Значення типу A & B зобов'язане одночасно належати і A, і B.
Однак статичне об'єднання типів вимагає практичного механізму роботи з конкретними варіантами в рантаймі. У TypeScript за це відповідає звуження типів (Type Narrowing) на основі аналізу потоку керування (Control Flow Analysis, CFA).
Під час проходження гілок виконання коду (if, switch, тернарні оператори) компілятор відстежує перевірки та динамічно звужує вихідний широкий тип змінної до конкретного вузького підтипу. Вершиною цієї архітектури є патерн розмічених об'єднань (Discriminated Unions / Algebraic Data Types), що дозволяє моделювати неперетинні стани додатку з гарантією вичерпної перевірки (Exhaustiveness Checking) через тип never.
Поглянемо на звуження типів через призму потоку керування:
+-------------------------------------------------------------------------+
| CONTROL FLOW ANALYSIS & TYPE NARROWING |
+-------------------------------------------------------------------------+
| ВИХІДНИЙ ТИП: ApiResponse = LoadingState | SuccessState | ErrorState |
+-------------------------------------------------------------------------+
| switch (response.status) { |
| case 'loading': |
| // CFA знає: response СУВОРО LoadingState! |
| return renderSpinner(); |
| |
| case 'success': |
| // CFA знає: response СУВОРО SuccessState (є поле .data) |
| return renderData(response.data); |
| |
| case 'error': |
| // CFA знає: response СУВОРО ErrorState (є поле .message) |
| return renderError(response.message); |
| |
| default: |
| // Усі гілки покриті! response має тип never! |
| const _exhaustiveCheck: never = response; |
| } |
+-------------------------------------------------------------------------+Змінна надходить із широким типом об'єднання (наприклад, string | number).
Компілятор парсить предикат (typeof val === 'string' або 'data' in response).
Всередині if-блоку тип змінної фільтрується: відсікаються всі несумісні варіанти.
У блоці else компілятор автоматично залишає залишкове об'єднання (A | B без A = B).
Що є обов'язковою умовою для створення патерну Discriminated Union у TypeScript?
isLoading = true і водночас data містить застарілі дані).Node = IdentifierNode | CallExpressionNode | BinaryExpressionNode).type ({ type: 'LOGIN_SUCCESS'; payload: User }).unknown через кастомні предикати типів (val is TargetType).typeof, instanceof, in#TypeScript нативно розуміє стандартні оператори JavaScript:
function processInput(input: string | number | Date | { [key: string]: unknown }) {
// 1. typeof: звуження примітивів
if (typeof input === 'string') {
return input.toUpperCase(); // input: string
}
// 2. instanceof: перевірка інстанса класу
if (input instanceof Date) {
return input.toISOString(); // input: Date
}
// 3. typeof number
if (typeof input === 'number') {
return input.toFixed(2); // input: number
}
// 4. оператор in: перевірка наявності властивості в об'єкті
if ('error' in input) {
return `Error: ${String(input.error)}`;
}
return 'unknown object';
}is)#Коли вбудованих перевірок недостатньо, створюються функції-предикати з поверненим типом param is Type:
interface UserProfile {
id: string;
name: string;
avatarUrl?: string;
}
// Предикат типу: гарантує, що при true значення є UserProfile
function isUserProfile(item: unknown): item is UserProfile {
return (
typeof item === 'object' &&
item !== null &&
'id' in item &&
typeof (item as Record<string, unknown>).id === 'string' &&
'name' in item &&
typeof (item as Record<string, unknown>).name === 'string'
);
}
const rawData: unknown = JSON.parse('{"id": "usr_1", "name": "Elena"}');
if (isUserProfile(rawData)) {
// Компілятор знає: rawData — це суворо UserProfile!
console.log(`Welcome, ${rawData.name}!`);
}never#Використання never дозволяє захистити систему від неповної обробки гілок під час додавання нових варіантів:
type PaymentState =
| { status: 'idle' }
| { status: 'processing'; transactionId: string }
| { status: 'succeeded'; receiptUrl: string }
| { status: 'failed'; errorCode: number };
function handlePayment(state: PaymentState): string {
switch (state.status) {
case 'idle':
return 'Очікування платежу';
case 'processing':
return `В обробці (${state.transactionId})`;
case 'succeeded':
return `Успішно: ${state.receiptUrl}`;
case 'failed':
return `Помилка: #${state.errorCode}`;
default: {
// Якщо додати новий статус (наприклад, 'refunded'), компілятор не збере проєкт!
// TS2322: Type '...' is not assignable to type 'never'.
const _exhaustive: never = state;
throw new Error(`Необроблений статус: ${JSON.stringify(_exhaustive)}`);
}
}
}Що станеться під час збирання, якщо до типу `PaymentState` додати новий варіант `{ status: 'refunded' }`, але забути додати відповідний `case 'refunded':` у функцію `handlePayment`?
Спроба моделювати стан об'єкта через незалежні булеві прапорці:
// АНТИПАТЕРН: можливі абсурдні комбінації (isLoading = true і isError = true)!
interface BadState {
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
data?: string;
error?: Error;
}Рішення: Використовуйте Discriminated Union зі спільним полем status: 'idle' | 'loading' | 'success' | 'error'.
Використання (data as User) замість написання Type Guard приховує потенційні помилки типізації під час зміни формату відповіді бекенду.