TypeScript
Discriminated unions y exhaustividad
Modelado de estados con discriminated unions y comprobación exhaustiva para representar variantes válidas y detectar casos no gestionados al compilar.
- Última actualización
- Actualizada
- Nivel
- Fundamentos
TypeScript
Modelado de estados con discriminated unions y comprobación exhaustiva para representar variantes válidas y detectar casos no gestionados al compilar.
Una discriminated union usa una propiedad literal común para identificar variantes con formas diferentes.
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };La propiedad status existe en todas las variantes y cada una usa un literal distinto.
function render(
state: RequestState<Product[]>,
): string {
switch (state.status) {
case "idle":
return "Ready";
case "loading":
return "Loading";
case "success":
return `${state.data.length} products`;
case "error":
return state.error.message;
}
}Modelo débil:
type RequestState<T> = {
loading: boolean;
data?: T;
error?: Error;
};Permite:
{
loading: true,
data,
error,
}La unión evita combinaciones no válidas por construcción.
type Result<T, TError> =
| { ok: true; value: T }
| { ok: false; error: TError };El boolean literal puede ser discriminante.
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${String(value)}`);
}function describePayment(payment: Payment): string {
switch (payment.method) {
case "cash":
return `Cash: ${payment.receivedAmount}`;
case "card":
return `Card: ${payment.lastFourDigits}`;
default:
return assertNever(payment);
}
}Si aparece una nueva variante, payment deja de ser never en default.
En versiones modernas también puede usarse:
switch (payment.method) {
case "cash":
break;
case "card":
break;
default:
payment satisfies never;
}No produce código de validación; solo comprueba exhaustividad estática.
function label(status: Status): string {
switch (status) {
case "idle":
return "Idle";
case "loading":
return "Loading";
}
}Si falta una variante, el retorno explícito y noImplicitReturns pueden ayudar a detectarlo.
type OrderEvent =
| {
type: "order.created";
order: Order;
}
| {
type: "order.cancelled";
orderId: string;
reason: string;
}
| {
type: "order.completed";
orderId: string;
completedAt: Date;
};type CartAction =
| { type: "item.added"; item: CartItem }
| { type: "item.removed"; itemId: string }
| { type: "cart.cleared" };Cada action transporta únicamente los datos que necesita.
Peligroso:
type Event = {
type: "created" | "deleted";
product?: Product;
productId?: string;
};El discriminante no garantiza qué opcional existe. Separa variantes.
type Message =
| {
channel: "email";
payload: EmailPayload;
}
| {
channel: "sms";
payload: SmsPayload;
};Narrowing de channel relaciona el tipo de payload.
type AsyncState<T, TError = Error> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: TError };El discriminante debe existir realmente en los objetos.
const state: AsyncState<Product[]> = {
status: "success",
data: products,
};TypeScript no añade status al JavaScript generado.
type CheckoutResult =
| {
status: "completed";
orderId: string;
paymentId: string;
}
| {
status: "requires-action";
actionUrl: URL;
}
| {
status: "rejected";
reason: "inventory" | "payment";
};¿Por qué type: "success" | "error" junto a data? y error? es menos seguro que dos objetos separados?
Porque el tipo no relaciona cada literal con la propiedad que debe existir y permite combinaciones contradictorias o incompletas.
Type aliases e interfaces inicia el bloque de modelado de contratos reutilizables.