TypeScript Avanzado: Patrones Genéricos y Tipado Profundo para Arquitecturas Complejas

Manejo de Estado con Tipos Discriminados

Concepto claveLos tipos discriminados (también conocidos como tagged unions o discriminated unions) son un patrón en TypeScript que permite modelar estados mutuamente excluyentes con precisión de tipos. Imagina un sistema de gestión de pedidos donde un pedido puede estar en estado "pendiente", "en proceso" o "completado". Cada estado tiene datos específicos: "pendiente" tiene fecha de creación, "en proceso" tiene ID del responsable, y "completado" tiene fecha de finalización. Sin tipos discrimin
Tiempo de estudio
15 Min

Concepto clave

Los tipos discriminados (también conocidos como tagged unions o discriminated unions) son un patrón en TypeScript que permite modelar estados mutuamente excluyentes con precisión de tipos. Imagina un sistema de gestión de pedidos donde un pedido puede estar en estado "pendiente", "en proceso" o "completado". Cada estado tiene datos específicos: "pendiente" tiene fecha de creación, "en proceso" tiene ID del responsable, y "completado" tiene fecha de finalización. Sin tipos discriminados, tendrías que usar tipos opcionales que pueden llevar a errores en tiempo de ejecución.

La analogía del mundo real es una máquina expendedora: solo puedes seleccionar un producto a la vez (café, refresco o snack), y cada opción tiene un precio y un mecanismo de dispensación diferente. El tipo discriminado asegura que, al seleccionar "café", solo puedas acceder a propiedades relevantes como "temperatura" o "tamaño", no a "sabor" que pertenece a "refresco". Esto elimina estados inválidos en tiempo de compilación, reduciendo bugs en arquitecturas complejas.

Cómo funciona en la práctica

Para implementar tipos discriminados, define una unión de tipos donde cada miembro tenga una propiedad literal común (el discriminante) que TypeScript pueda usar para inferir el tipo exacto. Sigue estos pasos:

  1. Define interfaces o tipos para cada estado, incluyendo una propiedad discriminante con un valor literal único (ej: type: 'loading').
  2. Crea un tipo unión que combine todos los estados posibles.
  3. Usa guardias de tipo (if o switch) basados en el discriminante para acceder a propiedades específicas de cada estado de forma segura.

Ejemplo paso a paso para un sistema de autenticación:

// 1. Definir tipos para cada estado
type AuthState =
| { type: 'idle'; message: string }
| { type: 'loading'; progress: number }
| { type: 'authenticated'; user: { id: string; name: string } }
| { type: 'error'; code: number; details: string };

// 2. Función que maneja el estado de forma segura
function handleAuthState(state: AuthState): string {
switch (state.type) {
case 'idle':
return `Listo: ${state.message}`; // Acceso seguro a 'message'
case 'loading':
return `Cargando: ${state.progress}%`; // Acceso seguro a 'progress'
case 'authenticated':
return `Usuario: ${state.user.name}`; // Acceso seguro a 'user'
case 'error':
return `Error ${state.code}: ${state.details}`; // Acceso seguro a 'code' y 'details'
default:
const exhaustiveCheck: never = state; // Garantiza que todos los casos están cubiertos
throw new Error('Estado no manejado');
}
}

// 3. Uso en código
const currentState: AuthState = { type: 'authenticated', user: { id: '1', name: 'Ana' } };
console.log(handleAuthState(currentState)); // Output: "Usuario: Ana"

Este enfoque garantiza que, al cambiar el type, TypeScript restringe automáticamente las propiedades disponibles, previniendo accesos incorrectos como state.user cuando type es "idle".

Caso de estudio

En una aplicación de e-commerce con arquitectura compleja, modelamos el estado de un carrito de compras usando tipos discriminados para manejar carga, éxito y errores. Supongamos que el carrito puede estar en tres estados: vacío (sin items), activo (con items y total), o error (con mensaje de fallo). Implementamos esto para una API que gestiona actualizaciones en tiempo real.

type CartItem = { id: string; name: string; price: number; quantity: number };

type CartState =
| { status: 'empty'; timestamp: Date }
| { status: 'active'; items: CartItem[]; total: number; discountCode?: string }
| { status: 'error'; message: string; retryable: boolean };

// Función para calcular impuestos solo en estado 'active'
function calculateTax(state: CartState): number {
if (state.status === 'active') {
return state.total * 0.16; // TypeScript sabe que 'total' existe aquí
}
return 0; // Para otros estados, no hay impuestos
}

// Ejemplo de uso en un flujo de API
async function fetchCart(): Promise {
try {
const response = await fetch('/api/cart');
const data = await response.json();
if (data.items && data.items.length > 0) {
return {
status: 'active',
items: data.items,
total: data.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
discountCode: data.discountCode
};
} else {
return { status: 'empty', timestamp: new Date() };
}
} catch (err) {
return { status: 'error', message: 'Fallo en la red', retryable: true };
}
}

// Uso en componente de UI
const cartState = await fetchCart();
switch (cartState.status) {
case 'empty':
console.log(`Carrito vacío desde ${cartState.timestamp}`);
break;
case 'active':
console.log(`Items: ${cartState.items.length}, Total: $${cartState.total}`);
if (cartState.discountCode) {
console.log(`Código de descuento: ${cartState.discountCode}`);
}
break;
case 'error':
console.error(`Error: ${cartState.message}, Reintentable: ${cartState.retryable}`);
break;
}

Este caso muestra cómo los tipos discriminados permiten un manejo de estado robusto en APIs, evitando accesos a items cuando el carrito está vacío o en error, lo que es crítico para arquitecturas escalables.

Errores comunes

  • No usar un discriminante único: Si los tipos en la unión no tienen una propiedad literal común o tienen valores solapados, TypeScript no puede discriminar correctamente. Solución: Asegúrate de que cada tipo tenga una propiedad como type o status con valores únicos (ej: 'loading', 'success').
  • Olvidar el caso default en switch: Al usar switch, no incluir un caso default puede llevar a estados no manejados. Solución: Usa default con una asignación a never para garantizar exhaustividad, como se muestra en el ejemplo práctico.
  • Usar tipos opcionales en lugar de uniones: Modelar estados con propiedades opcionales (ej: { data?: T; error?: string }) permite estados inválidos como tener tanto data como error. Solución: Refactoriza a tipos discriminados para hacer estados mutuamente excluyentes.
  • No aprovechar la inferencia de TypeScript: Al escribir guardias de tipo manuales innecesarias en lugar de confiar en el discriminante. Solución: Deja que TypeScript infiera automáticamente basado en la propiedad discriminante; evita casts explícitos como as.
  • Ignorar el rendimiento en uniones grandes: En uniones con muchos miembros (más de 10), la inferencia puede ralentizar el compilador. Solución: Agrupa estados relacionados o usa técnicas avanzadas como branded types para casos extremos.

Checklist de dominio

  1. Puedo definir una unión discriminada con al menos 3 estados mutuamente excluyentes para un escenario del mundo real.
  2. Sé implementar una función que use un switch o if con el discriminante para manejar todos los casos de forma exhaustiva.
  3. He refactorizado código existente que usa tipos opcionales a tipos discriminados para eliminar estados inválidos.
  4. Puedo explicar cómo los tipos discriminados mejoran la seguridad de tipos en APIs asíncronas o manejo de estado global.
  5. Identifico y corrijo errores comunes como discriminantes no únicos o falta de exhaustividad en mi código.
  6. Uso tipos discriminados en combinación con genéricos para crear componentes reutilizables (ej: un hook de React para estado).
  7. Documento los tipos discriminados en mi equipo para asegurar consistencia en arquitecturas complejas.

Refactorizar un hook de React con tipos discriminados


En este ejercicio, refactorizarás un hook de React que maneja el estado de una solicitud de datos usando tipos opcionales a tipos discriminados para mejorar la seguridad de tipos. Sigue estos pasos:

  1. Analiza el código base: Se proporciona un hook useFetch que actualmente usa un tipo con propiedades opcionales, permitiendo estados inválidos (ej: data y error simultáneamente).
  2. Define los tipos discriminados: Crea un tipo unión FetchState con tres miembros: 'idle' (sin datos), 'loading' (en progreso), 'success' (con datos de tipo T), y 'error' (con mensaje de error). Usa una propiedad status como discriminante.
  3. Refactoriza el hook: Modifica el hook para usar FetchState en lugar del tipo original. Asegúrate de que las transiciones de estado (ej: al iniciar carga, al recibir datos) actualicen el discriminante correctamente.
  4. Implementa una función de utilidad: Añade una función getFetchMessage que tome un FetchState y devuelva un mensaje descriptivo basado en el estado, usando un switch exhaustivo.
  5. Prueba el hook: Escribe un ejemplo de uso en un componente que muestre diferentes UI para cada estado (ej: spinner para loading, datos para success, error para error).

Código base inicial (para referencia):

type FetchResult = {
data?: T;
error?: string;
loading: boolean;
};

function useFetch(url: string): FetchResult {
// Implementación simplificada
return { loading: false, data: undefined, error: undefined };
}

Pistas
  • Recuerda que en tipos discriminados, cada estado debe tener propiedades específicas; por ejemplo, 'success' debe incluir 'data' pero no 'error'.
  • Usa un switch con caso default que asigne a 'never' para garantizar que todos los estados están manejados en getFetchMessage.
  • Considera usar genéricos en FetchState para hacer el hook reutilizable para cualquier tipo de datos.

Evalua tu comprension

Completa el quiz interactivo de arriba para ganar XP.

Texto Leccion 2/20
Estas viendo
Manejo de Estado con Tipos Discriminados
Hablar por WhatsAppContactar por WhatsApp