В теории типов объединение (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 скрывает потенциальные ошибки типизации при изменении формата ответа бэкенда.