Menu

Discriminated unions em TypeScript: tagged unions explicadas

Uma discriminated union é uma union de tipos de objeto que compartilham uma propriedade-tag literal, como kind ou status. Verificar a tag estreita o objeto inteiro. Veja o padrão, o narrowing com switch, verificações exaustivas com never e como modelar resultados de API, estado de requisições e máquinas de estado.

Esta página tem editores executáveis - edite, execute e veja a saída na hora.

Uma discriminated union (também chamada de tagged union) é uma union de tipos de objeto que têm todos uma propriedade em comum, a tag, com um valor literal diferente em cada membro. Verificar a tag diz ao TypeScript qual membro você tem, e as outras propriedades desse membro ficam disponíveis.

Dentro de case "circle", ler shape.width seria um erro de compilação, porque o membro círculo não tem width. A tag é um dado comum: em tempo de execução é só uma propriedade string, e o switch é JavaScript puro.

O que torna uma union discriminada

O narrowing pela tag funciona quando três condições valem:

  1. Todo membro é um tipo de objeto.
  2. Todo membro tem a mesma propriedade (o discriminante). O nome fica a seu critério: kind, type, status e tag são comuns.
  3. Em cada membro, essa propriedade tem um literal type: um literal de string, number ou boolean, ou null/undefined.
type UiEvent =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; delta: number };

type ApiResponse =
  | { ok: true; data: string }    // boolean literal tags
  | { ok: false; error: string };

type Message =
  | { version: 1; text: string }  // number literal tags
  | { version: 2; text: string; lang: string };

Se um membro declara a tag como string simples em vez de um literal, comparar a tag deixa de estreitar a union, e as propriedades específicas de cada membro ficam inacessíveis (TS2339).

Narrowing pela tag

Qualquer verificação que o TypeScript entenda sobre a tag estreita o objeto: switch, if/else, === e !==, e até uma tag desestruturada, desde que seja const:

function describe(e: UiEvent): string {
  if (e.type === "click") return `click at ${e.x},${e.y}`;

  const { type } = e;        // destructured tags narrow too
  if (type === "keypress") return `key ${e.key}`;

  return `scroll by ${e.delta}`; // only "scroll" is left
}

Verificar uma propriedade com "radius" in shape também faz narrowing, mas comparar uma tag é mais claro de ler e torna possível a verificação exaustiva.

Verificações exaustivas

O maior benefício do padrão aparece quando a union cresce. Com um tipo de retorno explícito e sem default, o TypeScript sabe que o switch precisa cobrir todas as tags, então um membro novo sem case é erro de compilação:

index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

Essa mensagem não diz qual caso está faltando. O helper assertNever diz, e também lança um erro em tempo de execução se dados de fora (JSON, um cliente antigo) trouxerem uma tag que os tipos dizem não poder existir:

Adicione um quinto status sem case e a linha assertNever(state) mostra Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never', indicando o membro. Mais sobre isso na página sobre never.

Tornando impossíveis os estados impossíveis

O RequestState acima substitui um formato comum e mais fraco:

// Every combination is allowed, including nonsense
type LooseState<T> = {
  loading: boolean;
  data?: T;
  error?: string;
};

const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };

Com campos opcionais, "carregando com dados e com erro" passa na verificação de tipos, e todo leitor precisa adivinhar quais combinações podem acontecer de verdade. Com a discriminated union, data só existe no estado success e error só no error, então código que lê state.data precisa antes provar que está no estado success. O próprio tipo documenta os estados válidos.

Modelando resultados: sucesso ou falha

Uma tag boolean basta para "deu certo ou não deu". Este tipo Result é uma alternativa comum a lançar exceções para falhas esperadas, como uma entrada inválida:

Quem chama não consegue ler result.value sem antes verificar result.ok, que é exatamente a verificação fácil de esquecer com exceções ou com retornos null.

Máquinas de estado e reducers

Duas discriminated unions, uma para os estados e outra para as ações, descrevem uma máquina de estados. Um reducer faz switch sobre a ação e retorna o próximo estado; o compilador verifica que cada objeto retornado é um estado válido:

{ ...state, name: "paused" } só compila porque state foi estreitado antes para o membro playing, então a cópia leva track e position. Retornar só { name: "paused" } seria um erro: um estado pausado precisa de uma faixa. É o mesmo formato que os reducers do Redux e o useReducer do React usam.

A armadilha do widening

A tag precisa continuar sendo um literal type. Um objeto montado em uma variável sem anotação tem a tag alargada para string, e aí não corresponde a nenhum membro:

type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;

const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.

const ok1: Shape = { kind: "circle", radius: 2 };        // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const;      // or keep the literal with as const
area({ kind: "circle", radius: 2 });                     // or build it where Shape is expected

A regra por trás disso, de que propriedades mutáveis são alargadas, é explicada na página sobre literal types.

Perguntas frequentes

O que é uma discriminated union no TypeScript?

Uma union de tipos de objeto em que todo membro tem a mesma propriedade (o discriminante, ou tag) com um literal type diferente, por exemplo { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. Verificar shape.kind === "circle" estreita shape para o membro círculo, e as outras propriedades dele ficam disponíveis.

Qual é a diferença entre uma union e uma discriminated union?

Uma discriminated union é uma union com uma regra a mais: todos os membros compartilham uma propriedade com um literal type único. Uma union simples como Cat | Fish precisa de narrowing com in ou type guards próprios; uma discriminated union é estreitada comparando uma propriedade, e um switch sobre essa propriedade pode ter a exaustividade verificada.

Como tornar exaustivo um switch sobre uma discriminated union?

Ou dê à função um tipo de retorno explícito e nenhum default (um caso faltando gera então o erro TS2366), ou adicione default: return assertNever(value) com function assertNever(x: never): never { throw ... }. A segunda forma indica no erro o membro não tratado e também lança um erro em tempo de execução se chegarem dados inesperados.

Por que minha discriminated union não faz narrowing?

A tag precisa ser um literal type em todos os membros. Se um objeto foi criado sem anotação, o kind dele é alargado para string e ele deixa de corresponder a qualquer membro (TS2345/TS2322). Corrija com uma anotação, com as const ou criando o objeto onde o union type é esperado. Um membro tipado como kind: string também impede o narrowing da union por kind.

O discriminante pode ser um boolean ou um número?

Sim. Qualquer literal type funciona: strings (kind: "circle"), números (version: 2), booleans (ok: true / ok: false) e até null ou undefined. Tags de string são as mais comuns porque ficam legíveis no log ou quando enviadas como JSON.

Coddy programming languages illustration

Aprenda a programar com o Coddy

COMEÇAR