Uniones Discriminadas
Receta
Usa tipos de unión discriminados para modelar props de componentes que cambian de forma basándose en un campo de variante o estado. Logra coincidencia de patrones exhaustivos para que TypeScript detecte casos faltantes en tiempo de compilación.
Ejemplo Funcional
// Props que cambian de forma basándose en un discriminante "variant"
type AlertProps =
| { variant: "success"; message: string }
| { variant: "error"; message: string; retryAction: () => void }
| { variant: "loading" };
function Alert(props: AlertProps) {
switch (props.variant) {
case "success":
return <div className="alert-success">{props.message}</div>;
case "error":
return (
<div className="alert-error">
<p>{props.message}</p>
<button onClick={props.retryAction}>Reintentar</button>
</div>
);
case "loading":
return <div className="alert-loading">Cargando...</div>;
}
}
// Uso
<Alert variant="success" message="¡Guardado!" />
<Alert variant="error" message="Falló" retryAction={() => refetch()} />
<Alert variant="loading" />// Ayudante de verificación exhaustiva
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
function getStatusColor(props: AlertProps): string {
switch (props.variant) {
case "success": return "green";
case "error": return "red";
case "loading": return "gray";
default: return assertNever(props);
// Si añades una nueva variante y olvidas manejarla,
// TypeScript lanzará un error en esta línea.
}
}Análisis Profundo
Cómo Funciona
- Una unión discriminada tiene un campo literal común (el "discriminante") que TypeScript usa para estrecharse. En
AlertProps, el discriminante esvariant. - Dentro de un bloque
switchoifque verifica el discriminante, TypeScript se estrecha al miembro específico de la unión. En el caso"error",props.retryActionestá disponible porque TypeScript sabe quepropses{ variant: "error"; message: string; retryAction: () => void }. - El patrón
assertNeverdetecta casos faltantes en tiempo de compilación. Si añades una nueva variante a la unión pero olvidas manejarla en el switch, TypeScript lanzará un error porque el nuevo tipo de variante no es asignable anever. - Las uniones discriminadas son más type-safe que los props opcionales porque hacen que los estados imposibles sean inrepresentables. No puedes pasar
retryActiona una alerta"success".
Variaciones
Patrón de datos asincronos:
type AsyncData<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function UserProfile({ state }: { state: AsyncData<User> }) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return <div>{state.data.name}</div>;
case "error":
return <div>Error: {state.error.message}</div>;
}
}Unión de campo de formulario:
type FormField =
| { type: "text"; label: string; placeholder?: string }
| { type: "select"; label: string; options: string[] }
| { type: "checkbox"; label: string; checked: boolean };
function FormFieldComponent({ field }: { field: FormField }) {
switch (field.type) {
case "text":
return <input type="text" placeholder={field.placeholder} />;
case "select":
return (
<select>
{field.options.map((opt) => <option key={opt}>{opt}</option>)}
</select>
);
case "checkbox":
return <input type="checkbox" defaultChecked={field.checked} />;
}
}Props condicionales sin discriminante:
type ModalProps =
| { dismissible: true; onDismiss: () => void }
| { dismissible?: false };
// TypeScript hace que: si dismissible es true, onDismiss es requeridoNotas de TypeScript
- El campo discriminante debe ser un tipo literal (string literal, number literal o boolean literal). Un campo de tipo
stringno funciona para el estrechamiento. - Puedes usar estrechamiento con el operador
incomo alternativa:if ("retryAction" in props)se estrecha a la variante error. - La palabra clave
satisfiespuede validar que un objeto coincida con una unión sin ampliar el tipo.
Trampas
- Desestructurar props antes del switch rompe el estrechamiento. Escribe
switch (props.variant)noconst { variant } = props; switch (variant)-- este último pierde la conexión entrevarianty el resto de props. - Añadir un nuevo miembro de unión sin actualizar todos los statements switch es un bug silencioso a menos que uses el patrón
assertNevero habilitesnoFallthroughCasesInSwitch. - Las uniones discriminadas solo se estrechan cuando TypeScript puede ver la verificación del discriminante. Extraer la verificación a una función separada requiere un type predicate.
- Usar
default: return nullen lugar deassertNeverno detectará casos faltantes en tiempo de compilación.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Uniones discriminadas | Los estados imposibles son inrepresentables | Definiciones de tipo más verbosas |
| Props opcionales | Definiciones de tipo más simples | Permite combinaciones de props inválidas |
| Discriminante Enum | Constantes nombradas, autocompletado de IDE | Los enums tienen sobrecarga en tiempo de ejecución, se prefieren uniones de string |
| Componentes polimórficos | Un componente, muchas formas | Firmas de tipo complejas |
| Componentes separados por variante | Cada componente es simple y enfocado | Lógica compartida duplicada |
FAQs
¿Qué es una unión discriminada y cuál es el "discriminante"?
- Una unión discriminada es un tipo de unión donde cada miembro tiene un campo literal común (el discriminante).
- TypeScript usa el discriminante para estrecharse dentro de bloques
switchoif. - Ejemplo: en
{ variant: "success" } | { variant: "error" }, el discriminante esvariant.
¿Por qué las uniones discriminadas son más type-safe que los props opcionales?
- Hacen que los estados imposibles sean inrepresentables.
- No puedes pasar
retryActiona una alerta"success"-- el sistema de tipos lo previene. - Los props opcionales permiten cualquier combinación, incluyendo las inválidas.
¿Qué hace el patrón assertNever y por qué es útil?
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}- Colocado en el caso
defaultde un switch, detecta casos faltantes en tiempo de compilación. - Si añades un nuevo miembro de unión pero olvidas manejarlo, TypeScript lanza un error porque el nuevo tipo no es asignable a
never.
¿Puede el campo discriminante ser un número o booleano, o debe ser una string?
- El discriminante debe ser un tipo literal: string literal, number literal o boolean literal.
- Un tipo simple
stringonumberno funciona para el estrechamiento. - Los string literals son la opción más común.
Trampa: ¿Por qué desestructurar props antes del switch rompe el estrechamiento?
- Escribir
const { variant } = props; switch (variant)rompe la conexión entrevarianty el resto deprops. - TypeScript ya no puede estrecharse al miembro correcto de la unión.
- Siempre usa
switch (props.variant)para preservar el estrechamiento.
¿Cómo modelas estados de carga de datos asincronos con una unión discriminada?
type AsyncData<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };- Cada estado tiene exactamente los campos que necesita.
datasolo es accesible cuandostatuses"success".
¿Puedes usar el operador in en lugar de un campo discriminante para el estrechamiento?
- Sí.
if ("retryAction" in props)se estrecha al miembro de la unión que tieneretryAction. - Funciona sin un campo discriminante dedicado.
- Solo verifica la existencia de propiedades, no el tipo de valor.
Trampa: ¿Qué sucede si usas default: return null en lugar de assertNever en un switch?
- TypeScript no detectará casos faltantes en tiempo de compilación.
- Los nuevos miembros de unión añadidos después caerán silenciosamente al caso default.
- Usa
assertNeverpara obtener verificación de exhaustividad en tiempo de compilación.
¿Cómo funciona la palabra clave satisfies con uniones discriminadas?
satisfiesvalida que un objeto coincida con una unión sin ampliar su tipo.- El tipo inferido preserva los valores literales específicos.
- Útil para objetos de configuración que deben conformarse a una forma de unión.
¿Cómo tipas props condicionales sin un campo discriminante?
type ModalProps =
| { dismissible: true; onDismiss: () => void }
| { dismissible?: false };- TypeScript hace que
onDismisssea requerido solo cuandodismissibleestrue. - Cuando
dismissibleesfalseu omitido,onDismissno puede ser pasado.
¿Cuándo elegirías componentes separados por variante sobre un componente de unión discriminada único?
- Los componentes separados son más simples y enfocados, sin lógica switch.
- Un componente único con una unión discriminada evita duplicar lógica de renderizado compartida.
- Elige basándote en cuánta lógica se comparte entre variantes.