Adicionou um estado. Esqueceu-se do handler. Nada o avisou.

As state machines começam limpas. Três valores, três branches do switch. Depois a lógica de retry adiciona um quarto estado. A falha parcial adiciona um quinto. Atualiza o reducer, mas esquece o componente de status badge, o mapper de analytics e o formatter de export.

Tudo compila. O bug aparece duas sprints depois, quando um utilizador atinge um ecrã em branco num corner case que nunca testou manualmente.

O TypeScript pode tornar isto impossível. Não com uma regra de linter. Não com um teste. Com o próprio sistema de tipos.

O que a exhaustiveness checking realmente significa

Exhaustiveness checking é o compiler a provar que tratou todas as variantes de um tipo. Rust e OCaml fazem isto por padrão. TypeScript também tem, mas tem de o pedir.

O truque combina duas coisas: discriminated unions e um helper que aceita apenas o tipo never.

Como as discriminated unions modelam estados

Uma discriminated union é um tipo TypeScript em que cada variante tem uma propriedade literal partilhada, chamada o discriminant. Para state machines, isto é quase sempre um campo status, kind ou type.

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

Cada variante transporta exatamente os dados relevantes para esse estado. Não há campo data opcional em idle, nem campo message em success. A forma do objeto diz-lhe em que estado está, e o compiler narrow o tipo automaticamente dentro de um 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", o tipo é { status: "loading"; requestId: string }, por isso state.requestId está disponível. O compiler faz narrowing automaticamente porque o literal status é diferente para cada variante.

Isto supera string enums e boolean flag soup. Mas ainda não é exhaustive. Remova o caso error e o TypeScript ainda compila. A função retorna implicitamente undefined, e o compiler mantém-se calado.

O truque never que força o handling exhaustive

Aqui está o helper:

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

Esta função aceita apenas never, a união vazia, o tipo sem valores. É impossível chamar assertNever com qualquer valor real a menos que tenha mentido ao compiler.

Agora adicione-o ao fundo do seu 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)
  }
}

Se todas as variantes possíveis de State forem tratadas acima do default, então state dentro do branch default foi narrow para never. A chamada a assertNever(state) compila.

Se adicionar um novo estado e se esquecer de um caso, o branch default agora recebe um tipo real. Não pode passar um tipo real para um parâmetro never. O compiler lança um type error.

Vê-lo falhar na prática

Adicione um estado retrying:

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

Agora handleState falha a compilar:

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

O erro aponta diretamente para o branch default. Não é um runtime crash. É uma recusa em compile-time de construir até decidir o que retrying significa nesta função específica.

Cada função que faz switch em State obtém o seu próprio erro. O compiler não o deixa adiar o trabalho.

O padrão escala para qualquer union type

Isto não é específico de state machines. Qualquer discriminated union beneficia do mesmo tratamento.

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 no 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)
  }
}

União de objetos com um discriminant literal. Faça switch sobre ela. Default para assertNever. Adicione uma variante, e todos os switches explodem até a tratar.

Onde isto falha e o que fazer a respeito

Exhaustiveness checking não é magia. Conheça as arestas afiadas antes de a adotar em todo o lado.

Tem de usar uma discriminated union

Isto não funciona com plain string enums ou boolean flags. O compiler precisa de uma propriedade literal partilhada para fazer narrowing.

// Isto NÃO funciona para exhaustiveness checking
enum Status {
  Idle = "idle",
  Loading = "loading",
  Success = "success",
}

assertNever apenas prova exhaustiveness em unions fechadas. Com enums, o compiler assume que qualquer string correspondente é válida. Não consegue provar que perdeu um caso.

Não deve usar any nem type assertions

Se fizer cast através de as State ou tirar de uma resposta tipada como any, o compiler salta o narrowing. Valide nas bordas, depois confie nos tipos no interior.

assertNever lança em runtime como último recurso

A função é um guarda de compile-time primeiro, guarda de runtime segundo. Em teoria nunca atinge o throw. Na prática, um cast ou JSON stale faz dela a sua rede de segurança. Isso é melhor do que retornar silenciosamente undefined.

Fica barulhento com muitos branches

Adicionar um décimo primeiro caso dispara erros em todas as funções que fazem switch no tipo. Esse é o ponto. Mas se a sua união crescer além de seis ou sete variantes, considere dividi-la em sub-unions mais pequenas ou máquinas separadas.

Como introduzir isto sem reescrever tudo

Comece pela state machine que causa mais bugs.

  1. Encontre um campo status tipado como string ou um enum amplo. Substitua-o por uma union fechada.
  2. Adicione assertNever às funções que fazem switch sobre ele.
  3. Deixe o compiler guiá-lo.
  4. Adicione uma checklist de code review que exija assertNever em novos reducers.

Mesmo com XState, ainda vai escrever mappers e selectors que fazem switch em valores de estado. Essas funções ainda precisam de exhaustiveness checking.

FAQ

Isto funciona com if/else em vez de switch?

Sim, mas é frágil. Precisa de um else final que chama assertNever. Reordenar as condições ou adicionar um early return, e pode acidentalmente perder a verificação. switch é mais seguro porque o branch default é óbvio.

E se genuinamente não me importar com alguns estados numa função específica?

Trate-os explicitamente. Não os deixe cair para assertNever por acidente.

case "retrying":
  // No-op neste contexto
  return ""

O compiler força-o a tornar a decisão visível. Esse é o ponto.

Posso retornar um valor de assertNever?

Uma função que retorna never é atribuível a qualquer tipo de retorno, porque na verdade nunca retorna. Se quiser um fallback em vez de lançar:

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

O parâmetro value: never mantém a verificação de compile-time intacta.

Isto funciona em JavaScript?

Não. Isto é uma funcionalidade de compile-time do TypeScript. Em runtime, assertNever é apenas uma função que lança. A segurança vem do type checker a recusar construir código que pudesse alcançá-la.

Faça do compiler o seu segundo revisor

De cada vez que adiciona um estado, cria trabalho no reducer, na UI, na pipeline de analytics, na lógica de export. O resultado por padrão é que perde um desses lugares, faz ship, e descobre mais tarde.

Exhaustiveness checking inverte o padrão. O compiler torna-se um revisor que nunca se esquece de verificar os switch statements. Não substitui testes, mas deteta uma categoria de bugs que os testes raramente cobrem: aquela em que simplesmente não sabia que tinha de escrever código.

Adicione o helper. Use-o num reducer. Da próxima vez que estender uma state machine, deixe o TypeScript dizer-lhe onde está o trabalho. Não perderá um branch.