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:

  1. Adicione assertNever às suas utilidades compartilhadas. Use-o em novos blocos switch.
  2. Quando você tocar em switches existentes sobre unions, adicione a guarda nessa hora.
  3. 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.
  4. Avalie o ts-pattern se 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.