En la teoría de tipos, la unión (Union, A | B) y la intersección (Intersection, A & B) representan operaciones fundamentales sobre conjuntos de valores. Un valor de tipo A | B pertenece al conjunto A, al conjunto B o a ambos simultáneamente. Un valor de tipo A & B debe pertenecer simultáneamente tanto a A como a B.
Sin embargo, una unión estática de tipos requiere un mecanismo práctico para trabajar con variantes específicas en tiempo de ejecución. En TypeScript, esto se logra mediante la reducción de tipos (Type Narrowing) basada en el análisis del flujo de control (Control Flow Analysis, CFA).
Al evaluar bifurcaciones de código (if, switch, operadores ternarios), el compilador rastrea las comprobaciones y reduce dinámicamente el tipo amplio inicial de una variable a un subtipo más estrecho y específico. La cúspide de esta arquitectura es el patrón de uniones discriminadas (Discriminated Unions / Tipos de Datos Algebraicos), que permite modelar estados disjuntos de una aplicación garantizando una comprobación exhaustiva (Exhaustiveness Checking) mediante el tipo never.
Examinemos la reducción de tipos a través del prisma del flujo de control:
+-------------------------------------------------------------------------+
| CONTROL FLOW ANALYSIS & TYPE NARROWING |
+-------------------------------------------------------------------------+
| TIPO INICIAL: ApiResponse = LoadingState | SuccessState | ErrorState |
+-------------------------------------------------------------------------+
| switch (response.status) { |
| case 'loading': |
| // CFA sabe: ¡response es STRICTAMENTE LoadingState! |
| return renderSpinner(); |
| |
| case 'success': |
| // CFA sabe: ¡response es STRICTAMENTE SuccessState (tiene .data) |
| return renderData(response.data); |
| |
| case 'error': |
| // CFA sabe: response es STRICTAMENTE ErrorState (tiene .message) |
| return renderError(response.message); |
| |
| default: |
| // ¡Todas las ramas están cubiertas! response es de tipo never! |
| const _exhaustiveCheck: never = response; |
| } |
+-------------------------------------------------------------------------+La variable ingresa con un tipo de unión amplio (por ejemplo, string | number).
El compilador analiza el predicado (typeof val === 'string' o 'data' in response).
Dentro del bloque if, el tipo de la variable se filtra eliminando todas las opciones incompatibles.
En el bloque else, el compilador conserva automáticamente la unión residual (A | B excluyendo A).
¿Cuál es un requisito obligatorio para crear el patrón Discriminated Union en TypeScript?
isLoading = true y simultáneamente data contiene datos desactualizados).Node = IdentifierNode | CallExpressionNode | BinaryExpressionNode).type ({ type: 'LOGIN_SUCCESS'; payload: User }).unknown desconocidas a través de predicados de tipo personalizados (val is TargetType).typeof, instanceof, in#TypeScript comprende de forma nativa los operadores estándar de JavaScript:
function processInput(input: string | number | Date | { [key: string]: unknown }) {
// 1. typeof: reducción de primitivos
if (typeof input === 'string') {
return input.toUpperCase(); // input: string
}
// 2. instanceof: verificación de instancia de clase
if (input instanceof Date) {
return input.toISOString(); // input: Date
}
// 3. typeof number
if (typeof input === 'number') {
return input.toFixed(2); // input: number
}
// 4. operador in: verificación de presencia de propiedad en un objeto
if ('error' in input) {
return `Error: ${String(input.error)}`;
}
return 'unknown object';
}is)#Cuando las comprobaciones integradas no son suficientes, se crean funciones predicado cuyo tipo de retorno es param is Type:
interface UserProfile {
id: string;
name: string;
avatarUrl?: string;
}
// Predicado de tipo: garantiza que si es true, el valor es 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)) {
// El compilador sabe: ¡rawData es strictly UserProfile!
console.log(`Welcome, ${rawData.name}!`);
}never#El uso de never permite proteger el sistema contra el procesamiento incompleto de ramificaciones al agregar nuevas variantes:
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 'Esperando pago';
case 'processing':
return `En proceso (${state.transactionId})`;
case 'succeeded':
return `Exitoso: ${state.receiptUrl}`;
case 'failed':
return `Error: #${state.errorCode}`;
default: {
// Si se agrega un nuevo estado (por ejemplo, 'refunded'), ¡el compilador no construirá el proyecto!
// TS2322: Type '...' is not assignable to type 'never'.
const _exhaustive: never = state;
throw new Error(`Estado no controlado: ${JSON.stringify(_exhaustive)}`);
}
}
}¿Qué sucederá durante la compilación si agregamos una nueva variante `{ status: 'refunded' }` al tipo `PaymentState`, pero olvidamos agregar el correspondiente `case 'refunded':` en la función `handlePayment`?
Intentar modelar el estado de un objeto mediante banderas booleanas independientes:
// ANTIPATRÓN: ¡son posibles combinaciones absurdas (isLoading = true e isError = true)!
interface BadState {
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
data?: string;
error?: Error;
}Solución: Utilice una Discriminated Union con un campo común status: 'idle' | 'loading' | 'success' | 'error'.
Utilizar (data as User) en lugar de escribir un Type Guard oculta errores potenciales de tipado cuando cambia el formato de respuesta del backend.