Você refatorou um tipo Shape, adicionou uma nova variante e o TypeScript continuou verde. Seu CI passou. Seu deploy foi para o ar. Aí um usuário acertou um branch em runtime que retornou undefined, e sua aplicação quebrou em produção.
O culpado quase certamente foi um switch statement. O switch do TypeScript não oferece garantia de exhaustividade. Adicione um novo membro a uma union, e todo switch sobre essa union fica silenciosamente incompleto. O compiler não vai te impedir. Nem vai te avisar. Essa é a parte que confunde as pessoas: o TypeScript conhece o conjunto completo de possibilidades, mas switch statements não participam da verificação de exhaustividade.
O que significa verificação de exhaustividade na prática
Exhaustividade significa que o compiler consegue provar que todo valor possível foi tratado. Linguagens como Rust e OCaml impõem isso em tempo de compilação. TypeScript não, pelo menos não para switch statements.
Considere uma 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
}
}
Isso compila. Também retorna undefined quando shape.kind === "triangle". A assinatura da função promete um number, mas a implementação não entrega um. TypeScript não sinaliza o caso ausente porque blocos switch não são verificados quanto à exhaustividade.
O padrão de lookup com objeto
A substituição mais simples é um mapa de objeto simples. Cada chave mapeia para uma função handler. Você indexa no objeto com o discriminante, e o compiler verifica se a chave existe.
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);
}
Isso é melhor. Se você adicionar "polygon" ao Shape, o TypeScript vai reclamar que areaHandlers está faltando uma chave. O erro aponta para o lugar certo, e acontece em tempo de compilação.
Mas os casts são feios. Dá pra fazer melhor.
Handlers type-safe sem casting
O truque é mapear cada variante para uma função que recebe o tipo narrowed diretamente. Você usa um mapped type para preservar a relação entre a chave e a variante específica do 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);
}
Agora cada handler recebe um argumento devidamente narrowed. Sem casts manuais dentro das funções. O as never na última linha é uma pequena mancha, mas é localizada e segura. O valor real é que areaHandlers deve conter todas as chaves do ShapeKind. Remova uma, ou adicione uma nova variante ao Shape, e o build quebra.
Verificação de exhaustividade com assert de never
Algumas equipes preferem manter o switch, mas adicionar uma guarda de exhaustividade em tempo de compilação. Esse é um meio-termo pragmático. Você adiciona um branch default que recebe never, forçando o compiler a verificar se todos os casos foram tratados.
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);
}
}
Se você adicionar um novo tipo de forma e esquecer de adicionar um case, o branch default recebe um valor que não é never. O TypeScript dá erro porque você não pode atribuir um tipo concreto a never. O erro aponta para o switch, que é exatamente onde você quer.
Esse padrão é amplamente usado na Sentry e em outras bases de código TypeScript grandes. Não exige mudar a estrutura do seu código. Ele apenas faz o compiler trabalhar a seu favor, e não contra você.
Pattern matching com bibliotecas de terceiros
Se você quer algo mais próximo do match do Rust, bibliotecas como ts-pattern oferecem pattern matching com verificação de exhaustividade embutida.
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();
}
A chamada .exhaustive() é a chave. Se uma variante estiver faltando, o TypeScript reporta um erro. A biblioteca também suporta nested patterns, guards e wildcards, que podem substituir blocos switch aninhados complexos.
Isso é poderoso, mas adiciona uma dependência. Para uma única discriminated union, ts-pattern é exagero. Para bases de código com uso intenso de pattern matching, vale o bundle size.
Onde switch statements ainda vencem
Switch nem sempre está errado. Se seus cases precisarem de fall through, ou se a lógica for imperativa com early returns e side effects, um switch pode ser mais legível do que uma lookup table ou uma cadeia de chamadas .with().
O problema não é a sintaxe. É a falta de verificação de exhaustividade. Se você está usando switch, adicione a guarda assertNever. São duas linhas de código e ela fecha a brecha de segurança.
Como adotar isso sem reescrever tudo
Você não precisa refatorar todos os seus switches hoje. Aqui está uma ordem de operações prática:
- Adicione
assertNeveràs suas utilidades compartilhadas. Use-o em novos blocos switch. - Quando você tocar em switches existentes sobre unions, adicione a guarda nessa hora.
- Para código novo sobre discriminated unions, prefira o padrão de object-map se a lógica for pura e cada caso for independente.
- Avalie o
ts-patternse você se pegar escrevendo switches aninhados ou lógica de matching complexa mais de algumas vezes.
O objetivo não é eliminar switch statements. É fazer o compiler pegar o bug antes dos seus usuários.
Se você quer ver isso em action, tente remover um handler do exemplo AreaMap e veja o tsc reclamar. Aquela linha vermelha ondulada é a diferença entre um deploy tranquilo numa sexta-feira e uma página do pagerduty num fim de semana.