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:
- Todo membro é um tipo de objeto.
- Todo membro tem a mesma propriedade (o discriminante). O nome fica a seu critério:
kind,type,statusetagsão comuns. - 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.