Agregaste un estado. Olvidaste un handler. Nadie te advirtió.

Las state machines empiezan limpias. Tres valores, tres branches de switch. Después la lógica de reintento agrega un cuarto estado. El fallo parcial agrega un quinto. Actualizas el reducer, pero olvidas el componente de badge de estado, el mapper de analytics y el formateador de exportación.

Todo compila. El bug aparece dos sprints después cuando un usuario encuentra una pantalla en blanco en un caso esquina que nunca probaste manualmente.

TypeScript puede hacer esto imposible. No con una regla de linter. No con un test. Con el sistema de tipos en sí.

Qué significa realmente el exhaustiveness checking

El exhaustiveness checking es el compiler demostrando que manejaste cada variante de un tipo. Rust y OCaml lo hacen por defecto. TypeScript también lo tiene, pero tienes que pedirlo.

El truco combina dos cosas: discriminated unions, y un helper que acepta solo el tipo never.

Cómo las discriminated unions modelan estados

Una discriminated union es un tipo de TypeScript donde cada variante tiene una propiedad literal compartida, llamada el discriminante. Para state machines, esto es casi siempre un campo status, kind o type.

type State =
  | { status: "idle" }
  | { status: "loading"; requestId: string }
  | { status: "success"; data: string }
  | { status: "error"; message: string }

Cada variante lleva exactamente los datos relevantes para ese estado. No hay un campo opcional data en idle, ni un campo message en success. La forma del objeto te dice en qué estado estás, y el compiler narrowea el tipo automáticamente dentro de un switch.

function handleState(state: State): string {
  switch (state.status) {
    case "idle":
      return "Waiting..."
    case "loading":
      return `Loading ${state.requestId}`
    case "success":
      return state.data
    case "error":
      return state.message
  }
}

Dentro de case "loading", el tipo es { status: "loading"; requestId: string }, así que state.requestId está disponible. El compiler narrowea automáticamente porque el literal status es diferente para cada variante.

Esto supera a los string enums y a la sopa de boolean flags. Pero aún no es exhaustivo. Quita el caso error y TypeScript sigue compilando. La función retorna implícitamente undefined, y el compiler se queda callado.

El truco de never que fuerza el manejo exhaustivo

Aquí está el helper:

function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${JSON.stringify(value)}`)
}

Esta función acepta solo never, la join vacía, el tipo sin valores. Es imposible llamar a assertNever con cualquier valor real a menos que le hayas mentido al compiler.

Ahora agrégalo al final de tu switch:

function handleState(state: State): string {
  switch (state.status) {
    case "idle":
      return "Waiting..."
    case "loading":
      return `Loading ${state.requestId}`
    case "success":
      return state.data
    case "error":
      return state.message
    default:
      return assertNever(state)
  }
}

Si cada variante posible de State es manejada arriba del default, entonces state dentro de la branch default ha sido narroweado a never. La llamada a assertNever(state) compila.

Si agregas un nuevo estado y olvidas un caso, la branch default ahora recibe un tipo real. No puedes pasar un tipo real a un parámetro never. El compiler lanza un error de tipo.

Viéndolo fallar en la práctica

Agrega un estado retrying:

type State =
  | { status: "idle" }
  | { status: "loading"; requestId: string }
  | { status: "success"; data: string }
  | { status: "error"; message: string }
  | { status: "retrying"; attempt: number; lastError: string }

Ahora handleState falla al compilar:

Argument of type '{ status: "retrying"; attempt: number; lastError: string; }'
is not assignable to parameter of type 'never'.

El error apunta directamente a la branch default. No es un crash en runtime. Es un rechazo en compile-time a compilar hasta que decidas qué significa retrying en esta función específica.

Cada función que hace switch sobre State obtiene su propio error. El compiler no te deja posponer el trabajo.

El patrón escala a cualquier union type

Esto no es específico de state machines. Cualquier discriminated union se beneficia del mismo tratamiento.

Event handlers:

type Event =
  | { type: "USER_LOGIN"; userId: string }
  | { type: "USER_LOGOUT" }
  | { type: "PAGE_VIEW"; path: string }

function handleEvent(event: Event): void {
  switch (event.type) {
    case "USER_LOGIN":
      trackLogin(event.userId)
      return
    case "USER_LOGOUT":
      trackLogout()
      return
    case "PAGE_VIEW":
      trackPageView(event.path)
      return
    default:
      assertNever(event)
  }
}

Reducers al estilo Redux:

type Action =
  | { type: "increment"; amount: number }
  | { type: "decrement"; amount: number }
  | { type: "reset" }

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "increment":
      return state + action.amount
    case "decrement":
      return state - action.amount
    case "reset":
      return 0
    default:
      return assertNever(action)
  }
}

Join de objetos con un discriminante literal. Haz switch sobre ella. default a assertNever. Agrega una variante, y cada switch explota hasta que la manejes.

Dónde esto falla y qué hacer al respecto

El exhaustiveness checking no es magia. Conoce los bordes afilados antes de adoptarlo en todas partes.

Debes usar una discriminated union

Esto no funciona con string enums simples o boolean flags. El compiler necesita una propiedad literal compartida para hacer narrowing.

// Esto NO funciona para exhaustiveness checking
enum Status {
  Idle = "idle",
  Loading = "loading",
  Success = "success",
}

assertNever solo prueba exhaustividad en uniones cerradas. Con enums, el compiler asume que cualquier string coincidente es válido. No puede probar que olvidaste un caso.

No debes usar any ni type assertions

Si haces cast con as State o extraes de una respuesta de tipo any, el compiler salta el narrowing. Valida en los bordes, luego confía en los tipos adentro.

assertNever lanza en runtime como último recurso

La función es un guardia de compile-time primero, de runtime segundo. En teoría nunca llegas al throw. En la práctica, un cast o un JSON stale lo convierte en tu red de seguridad. Eso es mejor que retornar silenciosamente undefined.

Se vuelve ruidoso con muchas branches

Agregar un undécimo caso dispara errores en cada función que hace switch sobre el tipo. Ese es el punto. Pero si tu join crece más de seis o siete variantes, considera dividirla en sub-uniones más pequeñas o máquinas separadas.

Cómo introducir esto sin reescribir todo

Empieza con la state machine que causa más bugs.

  1. Encuentra un campo status tipado como string o un enum amplio. Reemplázalo con una join cerrada.
  2. Agrega assertNever a las funciones que hacen switch sobre él.
  3. Deja que el compiler te guíe.
  4. Agrega un checklist de code review que requiera assertNever en nuevos reducers.

Incluso con XState, seguirás escribiendo mappers y selectors que hacen switch sobre valores de estado. Esas funciones todavía necesitan exhaustiveness checking.

FAQ

¿Esto funciona con if/else en lugar de switch?

Sí, pero es frágil. Necesitas un else final que llame a assertNever. Reordena las condiciones o agrega un early return, y puedes perder el check accidentalmente. switch es más seguro porque la branch default es obvia.

¿Qué pasa si genuinamente no me importan algunos estados en una función específica?

Maneja los estados explícitamente. No dejes que caigan en assertNever por accidente.

case "retrying":
  // No-op en este contexto
  return ""

El compiler te fuerza a hacer la decisión visible. Ese es el punto.

¿Puedo retornar un valor desde assertNever?

Una función que retorna never es asignable a cualquier tipo de retorno, porque nunca retorna realmente. Si quieres un fallback en lugar de lanzar:

function assertNever(value: never, fallback: string): string {
  console.error("Unhandled state:", value)
  return fallback
}

El parámetro value: never mantiene intacto el check de compile-time.

¿Esto funciona en JavaScript?

No. Esto es una feature de compile-time de TypeScript. En runtime, assertNever es solo una función que lanza. La seguridad viene del type checker negándose a compilar código que pudiera alcanzarlo.

Haz del compiler tu segundo reviewer

Cada vez que agregas un estado, creas trabajo en el reducer, la UI, el pipeline de analytics, la lógica de exportación. El resultado por defecto es que olvidas uno de esos lugares, lo envías, y te enteras después.

El exhaustiveness checking invierte el default. El compiler se convierte en un reviewer que nunca olvida revisar los switch. No reemplaza los tests, pero detecta una categoría de bug que los tests raramente cubren: aquel donde simplemente no sabías que tenías que escribir código.

Agrega el helper. Úsalo en un reducer. La próxima vez que extiendas una state machine, deja que TypeScript te diga dónde está el trabajo. No se perderá una branch.