In type theory, Union (A | B) and Intersection (A & B) represent fundamental set operations over values. A value of type A | B belongs to set A, set B, or both simultaneously. A value of type A & B must belong to both A and B at the same time.
However, static type unions require a practical runtime mechanism to work with specific variants. In TypeScript, this is handled by Type Narrowing based on Control Flow Analysis (CFA).
As code navigates control flow branches (if, switch, ternary operators), the compiler tracks checks and dynamically narrows the original broad type of a variable down to a specific narrow subtype. The pinnacle of this architecture is the Discriminated Unions (Algebraic Data Types) pattern, which allows modeling disjoint application states with guaranteed Exhaustiveness Checking via the never type.
Let's look at type narrowing through the lens of control flow:
+-------------------------------------------------------------------------+
| CONTROL FLOW ANALYSIS & TYPE NARROWING |
+-------------------------------------------------------------------------+
| INITIAL TYPE: ApiResponse = LoadingState | SuccessState | ErrorState |
+-------------------------------------------------------------------------+
| switch (response.status) { |
| case 'loading': |
| // CFA knows: response is STRICTLY LoadingState! |
| return renderSpinner(); |
| |
| case 'success': |
| // CFA knows: response is STRICTLY SuccessState (has .data) |
| return renderData(response.data); |
| |
| case 'error': |
| // CFA knows: response is STRICTLY ErrorState (has .message) |
| return renderError(response.message); |
| |
| default: |
| // All branches covered! response has type never! |
| const _exhaustiveCheck: never = response; |
| } |
+-------------------------------------------------------------------------+A variable enters with a broad union type (e.g., string | number).
The compiler parses the predicate (typeof val === 'string' or 'data' in response).
Inside the if-block, the variable type is filtered: all incompatible variants are eliminated.
In the else block, the compiler automatically retains the residual union (A | B without A = B).
What is a mandatory requirement for creating the Discriminated Union pattern in TypeScript?
isLoading = true while data simultaneously contains stale data).Node = IdentifierNode | CallExpressionNode | BinaryExpressionNode).type ({ type: 'LOGIN_SUCCESS'; payload: User }).unknown responses via custom type predicates (val is TargetType).typeof, instanceof, in#TypeScript natively understands standard JavaScript operators:
function processInput(input: string | number | Date | { [key: string]: unknown }) {
// 1. typeof: primitive narrowing
if (typeof input === 'string') {
return input.toUpperCase(); // input: string
}
// 2. instanceof: checking class instances
if (input instanceof Date) {
return input.toISOString(); // input: Date
}
// 3. typeof number
if (typeof input === 'number') {
return input.toFixed(2); // input: number
}
// 4. in operator: checking property existence in an object
if ('error' in input) {
return `Error: ${String(input.error)}`;
}
return 'unknown object';
}is)#When built-in checks are not enough, user-defined type guard functions with return type param is Type are created:
interface UserProfile {
id: string;
name: string;
avatarUrl?: string;
}
// Type predicate: guarantees that when true, value is 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)) {
// Compiler knows: rawData is strictly UserProfile!
console.log(`Welcome, ${rawData.name}!`);
}never type#Using never protects the system from incomplete branch handling when adding new union variants:
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 'Pending payment';
case 'processing':
return `Processing (${state.transactionId})`;
case 'succeeded':
return `Success: ${state.receiptUrl}`;
case 'failed':
return `Error: #${state.errorCode}`;
default: {
// If a new status is added (e.g. 'refunded'), the project will fail to compile!
// TS2322: Type '...' is not assignable to type 'never'.
const _exhaustive: never = state;
throw new Error(`Unhandled status: ${JSON.stringify(_exhaustive)}`);
}
}
}What will happen during compilation if a new variant `{ status: 'refunded' }` is added to `PaymentState`, but you forget to add a corresponding `case 'refunded':` branch in `handlePayment`?
Attempting to model object state using independent boolean flags:
// ANTI-PATTERN: invalid state combinations are possible (e.g., isLoading = true and isError = true)!
interface BadState {
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
data?: string;
error?: Error;
}Solution: Use a Discriminated Union with a shared discriminator field like status: 'idle' | 'loading' | 'success' | 'error'.
Using (data as User) instead of writing a Type Guard hides potential type errors when the backend response schema changes.