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.
- Encontre um campo
statustipado comostringou um enum amplo. Substitua-o por uma union fechada. - Adicione
assertNeveràs funções que fazem switch sobre ele. - Deixe o compiler guiá-lo.
- Adicione uma checklist de code review que exija
assertNeverem 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.