Refactorizaste un tipo shape, agregaste una nueva variante, y TypeScript se mantuvo verde. Tu CI pasó. Tu deploy salió. Entonces un usuario entró en una branch de runtime que devolvió undefined, y tu app lanzó una excepción en producción.
El culpable casi con certeza fue un switch statement. El switch de TypeScript no ofrece garantía de exhaustividad. Agregas un nuevo miembro a una union, y cada switch sobre esa union se vuelve incompleto en silencio. El compiler no te detendrá. Ni siquiera te advertirá. Aquí es donde muchos tropiezan: TypeScript conoce el conjunto completo de posibilidades, pero los switch statements no participan en la verificación de exhaustividad.
Qué significa realmente la verificación de exhaustividad
Exhaustividad significa que el compiler puede probar que cada valor posible ha sido manejado. Lenguajes como Rust y OCaml lo exigen en compile time. TypeScript no, al menos no para los switch statements.
Considera una discriminated union:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
// triangle is missing — and TypeScript is fine with it
}
}
Esto compila. También devuelve undefined cuando shape.kind === "triangle". La firma de la función promete un number, pero la implementación no entrega uno. TypeScript no marca el caso faltante porque los bloques switch no se verifican por completitud.
El patrón de búsqueda en objetos
El reemplazo más simple es un mapa de objetos plano. Cada clave mapea a una función handler. Indexas en el objeto con el discriminante, y el compiler verifica que la clave exista.
const areaHandlers: Record<Shape["kind"], (shape: Shape) => number> = {
circle: (s) => Math.PI * (s as Extract<Shape, { kind: "circle" }>).radius ** 2,
rectangle: (s) =>
(s as Extract<Shape, { kind: "rectangle" }>).width *
(s as Extract<Shape, { kind: "rectangle" }>).height,
triangle: (s) =>
0.5 *
(s as Extract<Shape, { kind: "triangle" }>).base *
(s as Extract<Shape, { kind: "triangle" }>).height,
};
function area(shape: Shape): number {
return areaHandlers[shape.kind](shape);
}
Esto es mejor. Si agregas "polygon" a Shape, TypeScript se quejará de que areaHandlers le falta una clave. El error apunta al lugar correcto, y ocurre en compile time.
Pero los casts son feos. Podemos hacerlo mejor.
Type-safe handlers sin casting
El truco es mapear cada variante a una función que recibe el tipo narrowed directamente. Usas un mapped type para preservar la relación entre la clave y la variante específica de shape.
type ShapeKind = Shape["kind"];
type AreaMap = {
[K in ShapeKind]: (shape: Extract<Shape, { kind: K }>) => number;
};
const areaHandlers: AreaMap = {
circle: (s) => Math.PI * s.radius ** 2,
rectangle: (s) => s.width * s.height,
triangle: (s) => 0.5 * s.base * s.height,
};
function area(shape: Shape): number {
const handler = areaHandlers[shape.kind];
return handler(shape as never);
}
Ahora cada handler recibe un argumento correctamente narrowed. Sin casts manuales dentro de las funciones. El as never en la última línea es una pequeña imperfección, pero está localizada y es segura. El valor real es que areaHandlers debe contener cada clave en ShapeKind. Elimina una, o agrega una nueva variante a Shape, y el build se rompe.
Verificación de exhaustividad con un assert de never
Algunos equipos prefieren mantener el switch pero agregar un guard de exhaustividad en compile time. Este es un punto medio pragmático. Agregas una branch default que recibe never, forzando al compiler a verificar que todos los casos fueron manejados.
function assertNever(x: never): never {
throw new Error("Unexpected value: " + x);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}
Si agregas un nuevo shape kind y olvidas agregar un caso, la branch default recibe un valor que no es never. TypeScript arroja un error porque no puedes asignar un tipo concreto a never. El error apunta al switch, que es exactamente donde lo quieres.
Este patrón se usa ampliamente en Sentry y otras codebases grandes de TypeScript. No requiere cambiar la estructura de tu código. Simplemente hace que el compiler trabaje a tu favor en lugar de en tu contra.
Pattern matching con librerías de terceros
Si quieres algo más cercano al match de Rust, librerías como ts-pattern proveen pattern matching con verificación de exhaustividad incluida.
import { match, P } from "ts-pattern";
function area(shape: Shape): number {
return match(shape)
.with({ kind: "circle" }, (s) => Math.PI * s.radius ** 2)
.with({ kind: "rectangle" }, (s) => s.width * s.height)
.with({ kind: "triangle" }, (s) => 0.5 * s.base * s.height)
.exhaustive();
}
La llamada .exhaustive() es la clave. Si falta una variante, TypeScript reporta un error. La librería también soporta patrones anidados, guards y wildcards, que pueden reemplazar bloques switch anidados complejos.
Esto es poderoso, pero agrega una dependencia. Para una sola discriminated union, ts-pattern es overkill. Para codebases con mucho pattern matching, vale el bundle size.
Dónde los switch statements todavía ganan
El switch no siempre está mal. Si tus casos necesitan fall through, o si la lógica es imperativa con early returns y side effects, un switch puede ser más legible que una lookup table o una cadena de llamadas .with().
El problema no es la sintaxis. Es la falta de verificación de exhaustividad. Si estás usando switch, agrega el guard assertNever. Son dos líneas de código y cierran la brecha de seguridad.
Cómo adoptar esto sin reescribir todo
No necesitas refactorizar todos tus switches hoy. Aquí hay un orden de operaciones práctico:
- Agrega
assertNevera tus utilidades compartidas. Úsalo en nuevos bloques switch. - Cuando toques switches existentes sobre unions, agrega el guard en ese momento.
- Para código nuevo sobre discriminated unions, prefiere el patrón object-map si la lógica es pura y cada caso es independiente.
- Evalúa
ts-patternsi te encuentras escribiendo switches anidados o lógica de matching compleja más de unas pocas veces.
El objetivo no es eliminar los switch statements. Es hacer que el compiler atrape el bug antes que tus usuarios.
Si quieres ver esto en action, intenta eliminar un handler del ejemplo AreaMap y observa cómo se queja tsc. Esa línea roja ondulada es la diferencia entre un deploy tranquilo de viernes y una página de pagerduty el fin de semana.