En la teoría de tipos, las uniones discriminadas (Discriminated Unions, también conocidas como Tagged Unions, Algebraic Data Types o Sum Types) representan una estructura de datos capaz de asumir uno de varios estados estrictamente definidos. Cada estado posee una propiedad literal compartida (el discriminante), mediante la cual el análisis de flujo de control (Control Flow Analysis, CFA) de TypeScript calcula de forma determinista el tipo específico de cada rama.
En sistemas empresariales, las uniones discriminadas constituyen un mecanismo fundamental para hacer imposibles los estados no válidos en tiempo de compilación (Make Impossible States Impossible). El clásico antipatrón de crear una "bolsa de propiedades opcionales con banderas booleanas" provoca una explosión combinatoria de configuraciones de memoria potencialmente erróneas:
+-------------------------------------------------------------------------+
| COMPARACIÓN DE MODELOS DE ESTADO EN MEMORIA |
+-------------------------------------------------------------------------+
| [Antipatrón: Objeto plano con banderas booleanas] |
| interface BadState { |
| isLoading: boolean; |
| isError: boolean; |
| data?: UserData; |
| error?: Error; |
| } |
| Estados potenciales: 2^2 * 2 * 2 = 16 combinaciones |
| (Incluyendo estados absurdos: isLoading=true Y isError=true Y data!=null)|
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [Unión discriminada: Autómata finito estricto] |
| type RemoteData<T, E> = |
| | { status: 'idle' } |
| | { status: 'loading' } |
| | { status: 'success'; data: T } |
| | { status: 'error'; error: E }; |
| Exactamente 4 estados mutuamente excluyentes. ¡Los estados no válidos |
| son imposibles de representar! |
+-------------------------------------------------------------------------+El mecanismo de verificación exhaustiva (Exhaustive Checking) se fundamenta en la semántica del tipo never. En TypeScript, el tipo never es el conjunto vacío (Bottom Type). Ningún valor, excepto el propio never, puede asignarse a una variable de este tipo.
Cuando el análisis CFA evalúa todas las ramas case de una unión discriminada, el tipo de la variable dentro del bloque default se reduce a never. Al pasar esta variable a una función assertNever(x: never): never, la compilación concluye con éxito. No obstante, en cuanto un ingeniero añade una nueva variante a la unión y olvida actualizar la bifurcación, el tipo remanente se convierte en dicha variante nueva y el compilador emite un error de tipos.
+-------------------------------------------------------------------------+
| CANALIZACIÓN DE ESTRECHAMIENTO DE TIPOS EN SWITCH |
+-------------------------------------------------------------------------+
| Entrada: action: PaymentAction (Card | Crypto | BankWire) |
| | |
| |-- case 'card': action estrechado a CardPayment |
| |-- case 'crypto': action estrechado a CryptoPayment |
| |-- case 'bank_wire': action estrechado a BankWirePayment |
| | |
| `-- default: |
| Tipo remanente: never (todas las ramas agotadas) |
| assertNever(action) -> ¡Compilación exitosa! |
+-------------------------------------------------------------------------+
|
(Se añade una nueva variante: 'apple_pay')
v
+-------------------------------------------------------------------------+
| default: |
| Tipo remanente: ApplePayPayment |
| assertNever(action) |
| ERROR: Argument of type 'ApplePayPayment' is not assignable to 'never'|
+-------------------------------------------------------------------------+assertNever y coincidencia exhaustiva#La función assertNever recibe un argumento de tipo never y lanza una excepción en tiempo de ejecución si la ruta se alcanza indebidamente (por ejemplo, mediante aserciones de tipo inseguras o datos anómalos de APIs externas):
/**
* Garantiza la verificación exhaustiva de variantes de unión en tiempo de compilación.
* Lanza una excepción en tiempo de ejecución ante la infracción de un invariante de tipado.
*/
export function assertNever(value: never, customMessage?: string): never {
throw new Error(
customMessage ??
`[ExhaustiveCheckError] Variante de unión no gestionada: ${JSON.stringify(value)}`
);
}Analicemos una implementación empresarial de los estados de un pedido en una plataforma de comercio electrónico. Cada estado incluye únicamente los campos pertinentes:
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;
}
// Unión discriminada raíz
export type OrderState =
| OrderDraft
| OrderPendingPayment
| OrderPaid
| OrderCancelled
| OrderFulfilled;
/**
* Función pura que da formato al estado del pedido para presentación en la interfaz.
* El compilador garantiza la gestión estricta de las 5 variantes.
*/
export function formatOrderStatusMessage(state: OrderState): string {
switch (state.status) {
case 'draft':
return `Borrador de pedido creado el ${state.createdAt.toLocaleDateString()}`;
case 'pending_payment':
return `Pendiente de pago hasta ${state.expiresAt.toLocaleTimeString()}. Enlace: ${state.paymentGatewayUrl}`;
case 'paid':
return `Pagado (Transacción: ${state.transactionId})`;
case 'cancelled':
return `Pedido cancelado. Motivo: ${state.reason}`;
case 'fulfilled':
return `Entregado. Número de seguimiento: ${state.trackingNumber}`;
default:
// Al incorporar un nuevo estado (como 'refunded'), el compilador señalará el error aquí
return assertNever(state);
}
}Para construir canalizaciones declarativas de procesamiento de datos, podemos crear una función de orden superior matchStatus:
import { assertNever } from './assertNever';
type MatcherTable<U extends { status: string }, R> = {
[K in U['status']]: (variant: Extract<U, { status: K }>) => R;
};
/**
* Pattern matching declarativo para uniones discriminadas según la propiedad '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, `No se encontró controlador para el estado: ${value.status}`);
}
return handler(value as Extract<U, { status: typeof value.status }>);
}Definición de interfaces con una clave literal común (status) y creación del tipo de unión OrderState.
El compilador procesa switch/case. En cada rama, el tipo se estrecha mediante el literal discriminante.
En la sección default, el compilador calcula la diferencia de conjuntos: T_initial \ (A | B | C). Si la diferencia es vacía, el resultado es never.
Si el tipo en default es never, assertNever compila limpiamente. Si existe un tipo no gestionado, se emite el error 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',
/**
* Controlador exhaustivo de estados con garantía de gestión de todas las ramas
*/
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);
}
},
};Declarar la propiedad discriminante con un tipo primitivo general como string en lugar de literales de cadena impide que el compilador ejecute el estrechamiento de tipos.
// ❌ ANTIPATRÓN: status tiene el tipo primitivo general 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') {
// ERROR: ¡s no se estrechó a StateA! Acceder a s.count provoca el error TS2339
console.log(s.count);
}
}
// ✅ CORRECTO: Tipos literales estrictos
interface StateAGood { status: 'a'; count: number; }
interface StateBGood { status: 'b'; text: string; }
type MyStateGood = StateAGood | StateBGood;Usar un simple break o un bloque default: vacío oculta errores al incorporar nuevas variantes a la unión. El código compila sin advertencias, pero los nuevos casos fallan silenciosamente en tiempo de ejecución.
// ❌ ANTIPATRÓN: Omisión silenciosa de estados no gestionados
function processBad(state: OrderState) {
switch (state.status) {
case 'draft': return;
case 'paid': return;
// ¡Se olvidaron pending_payment, cancelled, fulfilled!
default:
break; // ¡No hay error de compilación, pero la lógica está rota!
}
}
// ✅ CORRECTO: assertNever fuerza un error de compilación
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); // ¡Error en tiempo de compilación ante cualquier omisión!
}
}Si en una misma unión un miembro utiliza type: 'A' y otro kind: 'B', TypeScript no podrá emplear un punto de entrada uniforme para el estrechamiento mediante switch (item.type).
// ❌ ANTIPATRÓN: Diferentes nombres de propiedades discriminantes
type Event =
| { type: 'click'; x: number }
| { kind: 'hover'; y: number }; // ¡Propiedades divergentes!
// ✅ CORRECTO: Nombre uniforme de discriminante para toda la unión
type DomainEvent =
| { type: 'click'; x: number }
| { type: 'hover'; y: number };El uso de una conversión insegura como assertNever(state as any) anula por completo la verificación de tipos del compilador, deshabilitando la protección estática.
// ❌ ANTIPATRÓN: Evasión del compilador con any
default:
return assertNever(state as any); // ¡El compilador no avisa y el bug llega a producción!Defina todas las fases válidas de la entidad y elimine las banderas duplicadas (isLoading, isError, hasData).
Establezca una propiedad literal común de cadena o número (status / type / kind) en cada interfaz de la unión.
Incorpore la función assertNever(x: never): never en la capa base de utilidades del proyecto.
Sustituya las comprobaciones dispersas if-else por estructuras switch con una cláusula default que invoque assertNever.
Añada una variante de prueba a la unión y verifique que el CI/CD y tsc detengan la compilación hasta actualizar todos los controladores.