Kodokon kodokon.com

never, exhaustividad y uniones discriminadas avanzadas

Modelas tus estados con uniones discriminadas y aseguras su exhaustividad con el tipo never.

10 min · 3 preguntas

Abrir esta lección en Kodokon

never es el tipo vacío: ningún valor lo habita. Aparece cuando el compilador demuestra que una ruta de código es imposible, por ejemplo tras agotar cada miembro de una unión. Esa propiedad lo convierte en una herramienta de diseño: si logras que el compilador diga never en la rama por defecto de un switch, tienes una prueba estática de que todos los casos están gestionados. Esta es la base de la comprobación de exhaustividad.

Descubriste las uniones discriminadas en el módulo anterior; ahora es el momento de convertirlas en una auténtica herramienta de diseño. Frente a una única interfaz atiborrada de campos opcionales, una unión discriminada hace que los estados imposibles sean irrepresentables: no puedes tener a la vez data y error, ni un éxito sin datos. Es el patrón de referencia para los estados de carga, los resultados de operaciones y los mensajes de eventos.

TYPESCRIPT
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: FetchState<string[]>): string {
  switch (state.status) {
    case "idle": return "Waiting";
    case "loading": return "Loading…";
    case "success": return state.data.join(", ");
    case "error": return state.error.message;
  }
}
data solo existe en la variante success: sin acceso en ningún otro lugar.

Ya te has cruzado con la guarda assertNever invocada en la rama default; disequemos ahora exactamente cómo funciona. Mientras cada caso esté cubierto, su parámetro tiene ahí el tipo never y todo compila. Añade una variante a la unión sin actualizar el switch, y la variante olvidada deja de ser asignable a never: error en tiempo de compilación, con el nombre del caso faltante en el mensaje. Conviertes un bug de producción en una compilación rechazada.

TYPESCRIPT
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; width: number; height: number };

function assertNever(value: never): never {
  throw new Error("Unhandled case: " + String(value));
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.side ** 2;
    case "rect": return shape.width * shape.height;
    default: return assertNever(shape);
  }
}
Añadir una variante a Shape rompe la compilación de area.

Dos sutilezas avanzadas merecen tu atención. Primero, la reducción por discriminante se propaga a las estructuras anidadas solo si compruebas directamente el objeto que las contiene; desestructurar el discriminante por su cuenta rompe el vínculo (salvo en los casos gestionados desde TypeScript 4.6 para la desestructuración conjunta). Segundo, never se absorbe en las uniones (string | never es simplemente string): esto es lo que permite a los tipos condicionales filtrar variantes, como hace Exclude.

TYPESCRIPT
type Event =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string };

type EventOf<T extends Event["type"]> =
  Extract<Event, { type: T }>;

type ClickEvent = EventOf<"click">;

function handle<T extends Event["type"]>(
  type: T,
  handler: (event: EventOf<T>) => void
): void {
  /* register the handler */
}

handle("click", (e) => console.log(e.x, e.y));
Extract filtra la unión por discriminante: el handler es preciso.

Prueba de conocimientos

Comprueba que has retenido los puntos clave de esta lección.

  1. ¿Por qué una unión discriminada es preferible a una interfaz con campos opcionales para un estado de carga?
    • Usa menos memoria en tiempo de ejecución
    • Hace irrepresentables las combinaciones incoherentes (data y error al mismo tiempo)
    • Evita escribir sentencias switch
    • Es obligatoria en modo estricto
  2. ¿Qué ocurre si añades una variante a Shape sin modificar el switch que contiene assertNever?
    • Se lanza una excepción en cada llamada a area
    • El código compila pero la nueva variante devuelve undefined
    • La compilación falla porque la variante olvidada no es asignable a never
  3. ¿Qué es el tipo string | never?
    • never
    • string
    • unknown
    • string | undefined