Type Checking Exhaustivo con Discriminated Unions
Cómo usar discriminated unions de TypeScript para type checking exhaustivo, asegurando que todos los casos se manejen en compile time con aserciones de tipo never.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Overview
Discriminated unions en TypeScript combinan múltiples object types que comparten una propiedad literal común (el discriminante). Cuando haces switch sobre el discriminante, TypeScript narrowea el tipo al branch que coincide. Al agregar un check de never en el case default, obtienes un error de compile time si se agrega una nueva variante pero no se maneja — esto es type checking exhaustivo.
When to Use
- State machines (idle, loading, success, error) donde cada estado tiene datos diferentes
- Action types en reducers (Redux, useReducer) donde cada action lleva payloads diferentes
- Handling de API responses (success con data, error con message, loading con progress)
- Estados de validación de formularios (valid, invalid con errors, pending)
- Event handling donde diferentes eventos llevan payloads diferentes
When NOT to Use
- Estados booleanos simples —
isLoading: booleanes suficiente, no hace falta un union - Escenarios de optional chaining — si solo necesitas
data?.value, un union añade complejidad - Type checking en runtime — discriminated unions son solo compile time; usa Zod o io-ts para runtime validation
Solution
Discriminated union básico
type Result<T, E = Error> =
| { status: "success"; data: T }
| { status: "error"; error: E }
| { status: "loading" };
function handleResult(result: Result<string>): string {
switch (result.status) {
case "success":
return result.data.toUpperCase();
case "error":
return result.error.message;
case "loading":
return "Loading...";
}
}
TypeScript narrowea result al tipo que coincide en cada case branch, así que result.data solo es accesible en el case success.
Checking exhaustivo con never
type Result<T, E = Error> =
| { status: "success"; data: T }
| { status: "error"; error: E }
| { status: "loading" };
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
function handleResult(result: Result<string>): string {
switch (result.status) {
case "success":
return result.data.toUpperCase();
case "error":
return result.error.message;
case "loading":
return "Loading...";
default:
return assertNever(result);
}
}
Si agregas una nueva variante { status: "idle" } al union sin agregar un case, TypeScript errora en assertNever(result) porque result ya no es never — es { status: "idle" }.
Patrón de state machine
type FetchState<T> =
| { state: "idle" }
| { state: "loading" }
| { state: "success"; data: T }
| { state: "error"; error: string };
type FetchAction<T> =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: T }
| { type: "FETCH_ERROR"; payload: string }
| { type: "RESET" };
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case "FETCH_START":
return { state: "loading" };
case "FETCH_SUCCESS":
return { state: "success", data: action.payload };
case "FETCH_ERROR":
return { state: "error", error: action.payload };
case "RESET":
return { state: "idle" };
default: {
const _exhaustive: never = action;
throw new Error(`Unhandled action: ${_exhaustive}`);
}
}
}
Usar discriminated unions con React
import { useReducer, useEffect } from "react";
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
type AsyncAction<T> =
| { type: "start" }
| { type: "success"; data: T }
| { type: "error"; error: string };
function asyncReducer<T>(state: AsyncState<T>, action: AsyncAction<T>): AsyncState<T> {
switch (action.type) {
case "start":
return { status: "loading" };
case "success":
return { status: "success", data: action.data };
case "error":
return { status: "error", error: action.error };
default: {
const _exhaustive: never = action;
throw new Error(`Unhandled action: ${_exhaustive}`);
}
}
}
function useAsync<T>(fn: () => Promise<T>) {
const [state, dispatch] = useReducer(asyncReducer<T>, { status: "idle" });
useEffect(() => {
dispatch({ type: "start" });
fn()
.then((data) => dispatch({ type: "success", data }))
.catch((error) => dispatch({ type: "error", error: String(error) }));
}, [fn]);
return state;
}
function UserProfile() {
const state = useAsync(() => fetch("/api/user").then((r) => r.json()));
switch (state.status) {
case "idle":
return <div>Click to load</div>;
case "loading":
return <div>Loading...</div>;
case "success":
return <div>{state.data.name}</div>;
case "error":
return <div>Error: {state.error}</div>;
default: {
const _exhaustive: never = state;
return <div>Unknown state</div>;
}
}
}
Discriminated union con propiedades compartidas
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rectangle"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "rectangle":
return shape.width * shape.height;
default: {
const _exhaustive: never = shape;
throw new Error(`Unknown shape: ${_exhaustive}`);
}
}
}
Usar el operador satisfies (TypeScript 4.9+)
type EventMap =
| { type: "click"; x: number; y: number }
| { type: "scroll"; scrollTop: number }
| { type: "input"; value: string };
const handler = {
click: (e: { x: number; y: number }) => console.log(`Clicked at ${e.x}, ${e.y}`),
scroll: (e: { scrollTop: number }) => console.log(`Scrolled to ${e.scrollTop}`),
input: (e: { value: string }) => console.log(`Input: ${e.value}`),
} satisfies Record<EventMap["type"], (e: any) => void>;
Checking exhaustivo con if-else
function handleEvent(event: EventMap): void {
if (event.type === "click") {
console.log(`Clicked at ${event.x}, ${event.y}`);
} else if (event.type === "scroll") {
console.log(`Scrolled to ${event.scrollTop}`);
} else if (event.type === "input") {
console.log(`Input: ${event.value}`);
} else {
const _exhaustive: never = event;
throw new Error(`Unhandled event: ${_exhaustive}`);
}
}
Discriminated union con campos opcionales
type ApiResponse<T> =
| { status: 200; data: T; cached?: boolean }
| { status: 404; error: string }
| { status: 500; error: string; stack?: string };
function processResponse<T>(response: ApiResponse<T>): T | null {
switch (response.status) {
case 200:
console.log(response.cached ? "From cache" : "Fresh data");
return response.data;
case 404:
console.error("Not found:", response.error);
return null;
case 500:
console.error("Server error:", response.error);
if (response.stack) console.error("Stack:", response.stack);
return null;
default: {
const _exhaustive: never = response;
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}
}
Variants
Usar discriminated unions con Zod para runtime validation
import { z } from "zod";
const EventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ type: z.literal("scroll"), scrollTop: z.number() }),
z.object({ type: z.literal("input"), value: z.string() }),
]);
type Event = z.infer<typeof EventSchema>;
function handleEvent(event: unknown) {
const parsed = EventSchema.parse(event); // Runtime validation
// parsed ahora está tipado como el discriminated union
switch (parsed.type) {
case "click":
console.log(parsed.x, parsed.y);
break;
case "scroll":
console.log(parsed.scrollTop);
break;
case "input":
console.log(parsed.value);
break;
default: {
const _exhaustive: never = parsed;
throw new Error(`Unhandled: ${_exhaustive}`);
}
}
}
Usar discriminated unions con arrays
type Task =
| { status: "pending"; id: string; createdAt: Date }
| { status: "in-progress"; id: string; startedAt: Date }
| { status: "done"; id: string; completedAt: Date; result: string };
function getActiveTasks(tasks: Task[]): Task[] {
return tasks.filter(
(task): task is Extract<Task, { status: "in-progress" }> =>
task.status === "in-progress"
);
}
function summarizeTasks(tasks: Task[]): string {
const pending = tasks.filter((t) => t.status === "pending").length;
const inProgress = tasks.filter((t) => t.status === "in-progress").length;
const done = tasks.filter((t) => t.status === "done").length;
return `${pending} pending, ${inProgress} in progress, ${done} done`;
}
Best Practices
-
For a deeper guide, see Complete Guide to TypeScript Advanced Types.
-
Siempre agrega un case
defaultcon aserciónnever— esto atrapa cases faltantes en compile time -
Usa un solo nombre de propiedad discriminante a través de todas las variantes (
status,type,kind) -
Usa string literals para discriminantes, no numbers — son más legibles en error messages
-
Mantén el discriminante como la primera propiedad en el tipo para legibilidad
-
Usa
satisfiespara asegurar que un objeto cubre todas las variantes del union sin perder type information -
Combina con Zod para runtime validation en boundaries de API — discriminated unions son solo compile time
Common Mistakes
- Olvidar el check de
never: sinassertNever, agregar una nueva variante no produce un compile error — el switch cae silenciosamente. - Usar discriminantes non-literal:
status: stringen lugar destatus: "success"— TypeScript no puede narrowear en strings arbitrarios. - No usar
breakoreturnen switch cases: fall-through causa bugs de runtime incluso cuando los tipos son correctos. - Mezclar nombres de propiedad discriminante:
{ type: "a" } | { kind: "b" }— TypeScript no puede narrowear porque no hay un discriminante común. - No manejar el case
neveren cadenas if-else: el mismo issue de exhaustiveness aplica a if-else que a switch.
FAQ
¿Qué es el tipo never?
never es el tipo de valores que nunca ocurren. En checking exhaustivo, después de que todos los cases se manejan, el tipo restante es never. Si se agrega una nueva variante, el tipo restante ya no es never, causando un type error.
¿Puedo usar discriminated unions con enums?
Sí, pero los string literals son preferidos. Los enums añaden indirection y no tree-shakean bien. Usa type Status = "idle" | "loading" | "success" | "error" en lugar de un enum.
¿Qué pasa si no agrego el check de never?
TypeScript no errora — el switch simplemente no tiene un case para la nueva variante. La función podría retornar undefined o caer al branch equivocado. El check de never es lo que lo hace exhaustivo.
¿Puedo usar discriminated unions con generics?
Sí. El parámetro generic se preserva a través del narrowing:
type Box<T> = { kind: "some"; value: T } | { kind: "none" };
function unwrap<T>(box: Box<T>): T | null {
return box.kind === "some" ? box.value : null;
}
¿Cómo se diferencian discriminated unions de regular unions?
Regular unions (string | number) no tienen una propiedad discriminante. TypeScript los narrowea con checks de typeof. Discriminated unions tienen una propiedad literal común sobre la que TypeScript narrowea con checks de igualdad — esto es más ergonomic y soporta tipos más ricos.
Recursos Relacionados
Build Reusable Utility Types with Generics
How to create reusable TypeScript utility types using conditional types, mapped types, template literals, and generic constraints for type-safe APIs.
RecipeWhen to Use useMemo and useCallback
How and when to use React's useMemo and useCallback hooks for performance optimization, and when they add unnecessary overhead.
RecipeForm Validation with react-hook-form and Zod
How to build type-safe forms in React using react-hook-form with Zod schema validation, including nested fields, async validation, and dynamic fields.
RecipeDesign Tokens with CSS Custom Properties
How to build a design token system using CSS custom properties, including color scales, spacing, typography, themes, and responsive scaling.
GuideComplete Guide to TypeScript Advanced Types
Master TypeScript advanced types: conditional types, mapped types, template literal types, infer, distributive types, and type-level programming patterns.