programandoai

in e discriminated unions: o coração do TS do dia a dia

Um objeto que representa 'carregando', 'sucesso' ou 'erro' é um dos padrões mais comuns de qualquer aplicação real. Discriminated unions modelam esse padrão com segurança total.

Estudar esta lição interativa →

Às vezes dois formatos de objeto não têm nenhum tipo primitivo para diferenciar com typeof, os dois são objetos. Nesse caso, o operador in pergunta, em tempo real, se uma propriedade específica existe naquele objeto, e o TypeScript usa a resposta para o narrowing:

// narrowing com "in": dois formatos de objeto, diferenciados por uma
// propriedade que só um dos dois tem.
interface Gato { patas: number; ronrona: true }
interface Passaro { patas: number; voa: true }

function descreverAnimal(animal: Gato | Passaro) {
  if ("voa" in animal) {
    return "Um pássaro com " + animal.patas + " patas, que voa.";
  }
  return "Um gato com " + animal.patas + " patas, que ronrona.";
}

console.log(descreverAnimal({ patas: 2, voa: true }));
console.log(descreverAnimal({ patas: 4, ronrona: true }));

Isso funciona, mas depender de "será que esta propriedade existe" é frágil: e se dois formatos, por coincidência, tiverem uma propriedade com o mesmo nome? O padrão que resolve isso de vez, e que você vai ver o tempo todo em código profissional de TypeScript, é a discriminated union (união discriminada): uma união de vários formatos de objeto que compartilham uma propriedade em comum, com um tipo literal diferente em cada um. Essa propriedade compartilhada é o campo discriminante.

O exemplo mais comum do mundo real é exatamente o que qualquer tela que busca dados de um servidor precisa representar: o carregamento ainda não terminou, terminou com sucesso, ou terminou com erro. Compare com a alternativa ingênua de várias variáveis soltas (carregando: boolean, dados: Produto[] | null, erro: string | null), que permite estados sem sentido, como carregando: true e erro: "falhou" ao mesmo tempo:

// Discriminated union: "status" é o campo discriminante, com um valor
// literal diferente em cada variante.
type EstadoPedidos =
  | { status: "carregando" }
  | { status: "sucesso"; dados: string[] }
  | { status: "erro"; erro: string };

function mensagemEstado(estado: EstadoPedidos): string {
  switch (estado.status) {
    case "carregando":
      return "Carregando pedidos...";
    case "sucesso":
      return estado.dados.length + " pedido(s) encontrado(s)."; // dados só existe aqui
    case "erro":
      return "Falhou: " + estado.erro; // erro só existe aqui
  }
}

console.log(mensagemEstado({ status: "carregando" }));
console.log(mensagemEstado({ status: "sucesso", dados: ["#1", "#2"] }));
console.log(mensagemEstado({ status: "erro", erro: "sem conexão" }));

O campo 'status' é o discriminante: cada variante tem um valor literal diferente para ele.

Pense no campo discriminante como a etiqueta de cor numa esteira de bagagens de aeroporto: bagagem com etiqueta vermelha vai para a área de "extraviada", com etiqueta verde vai para a "esteira normal". Você não precisa abrir a mala para saber o que fazer com ela, só olhar a etiqueta. Da mesma forma, o TypeScript não precisa "abrir" o objeto inteiro: só de ver status === "sucesso", ele já sabe, com certeza, que aquele objeto específico tem a propriedade dados, e libera o acesso a ela sem exigir nenhuma checagem extra.

Repare, no exemplo acima, que dentro de case "sucesso": o TypeScript permite estado.dados sem reclamar, mesmo sem nenhuma verificação manual adicional, porque o próprio case já provou, para aquele branch, que estado só pode ser a variante de sucesso. E dentro de case "erro":, tentar acessar estado.dados seria um erro de tipo: aquela variante nem tem essa propriedade.

Há ainda uma vantagem que aparece com o tempo: e se alguém acrescentar um quarto estado à união e esquecer de tratá-lo no switch? Sem proteção, a função devolveria undefined em silêncio. A proteção é o tipo never ("nunca", o tipo que não tem nenhum valor possível): no default, depois de todos os case, o TypeScript já eliminou todas as variantes, então estado tem tipo never. Se sobrar uma variante sem tratamento, ela deixa de ser never e o erro aparece ao escrever, não ao rodar. Isso se chama checagem de exaustividade:

type EstadoTela =
  | { status: "carregando" }
  | { status: "sucesso"; pedidos: string[] }
  | { status: "erro"; mensagem: string };

// Se algum caso ficar sem tratamento, o valor que chega aqui deixa de ser "never"
// e o TypeScript acusa o erro na hora de escrever, não na hora de rodar.
function garantirTodos(valor: never): never {
  throw new Error("Caso não tratado: " + JSON.stringify(valor));
}

function descrever(estado: EstadoTela): string {
  switch (estado.status) {
    case "carregando": return "Carregando...";
    case "sucesso": return estado.pedidos.join(", ");
    case "erro": return "Erro: " + estado.mensagem;
    default: return garantirTodos(estado);
  }
}

console.log(descrever({ status: "sucesso", pedidos: ["#1", "#2"] }));
console.log(descrever({ status: "erro", mensagem: "falha na rede" }));

// Experimento: acrescente { status: "vazio" } à união EstadoTela e NÃO crie o case.
// O default passa a dar erro de tipo (TS2345) e te avisa do case que faltou.

Experimento: acrescente uma variante { status: "vazio" } à união e veja o default acusar o erro.

Sem discriminante, a união vira um monte de "ou"

Uma união de objetos sem nenhuma propriedade em comum e literal, como { dados: Produto[] } | { mensagem: string }, obriga você a usar in toda vez, e ainda corre o risco de ambiguidade se os dois formatos crescerem com o tempo. Sempre que você estiver modelando "isto pode estar num destes estados diferentes", vale a pena desenhar de propósito um campo discriminante literal (como status, tipo ou kind) desde o início.

O type alias EstadoTela já modela os três estados de carregamento de uma lista de pedidos (carregando, sucesso com pedidos: string[], erro com mensagem: string). Complete a função renderizarTela(estado) com um switch no campo status: para "carregando", devolva "Carregando..."; para "sucesso", devolva pedidos.join(", ") (uma string vazia se a lista estiver vazia); para "erro", devolva "Erro: " + mensagem. Feche o switch com um default que atribui estado a uma constante do tipo never e a devolve (checagem de exaustividade): se alguém acrescentar uma variante à união e esquecer de tratá-la, o compilador deve acusar erro.
type EstadoTela =
  | { status: "carregando" }
  | { status: "sucesso"; pedidos: string[] }
  | { status: "erro"; mensagem: string };

function renderizarTela(estado: EstadoTela): string {
  // sua vez: switch (estado.status), um case para cada variante
  // e um default que atribui estado a uma constante do tipo never (exaustividade)
}

console.log(renderizarTela({ status: "carregando" }));

🧩 Este é um exercício com correção automática — abra no app para completá-lo e verificar.

← Voltar ao índice do curso