Discriminated Unions no TypeScript 2026
Domine discriminated unions no TypeScript. Modele estados com segurança, aplique exhaustive checks e construa state machines que o compilador valida pra você.
O Que São Discriminated Unions
Discriminated union (ou tagged union) é uma union de tipos que compartilham uma propriedade literal em comum. Essa propriedade é o discriminante: ela diz pro TypeScript qual variante da union está ativa.
O exemplo clássico: type Estado = { status: 'loading' } | { status: 'success'; data: string } | { status: 'error'; error: Error }. O campo status é o discriminante. Quando o TypeScript vê que status é 'success', ele sabe que data existe. Quando é 'error', sabe que error existe. Sem cast, sem verificação manual.
Isso funciona porque cada variante tem um valor literal diferente na propriedade discriminante. O TypeScript faz narrowing automático em switch/case e em if/else. Você não precisa criar type guards customizados pra isso.
O poder real aparece com exhaustive checks usando o tipo never. Se você adiciona uma nova variante na union e esquece de tratar num switch, o compilador acusa erro. Zero chance de esquecer um caso.
Como Usar Discriminated Unions Passo a Passo
Do conceito à state machine completa, cada passo adiciona uma camada de segurança.
Exemplos Práticos de Discriminated Unions
Vamos ver cada padrão em ação com código que você copia e adapta.
Estado de Requisição HTTP
// Cada estado tem exatamente os campos que precisa
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string; retryCount: number };
interface User {
id: number;
name: string;
}
function renderUser(state: RequestState<User>): string {
switch (state.status) {
case "idle":
return "Clique para carregar";
case "loading":
return "Carregando...";
case "success":
// TypeScript sabe: state.data existe e é User
return `Usuário: ${state.data.name}`;
case "error":
// TypeScript sabe: state.error e state.retryCount existem
return `Erro: ${state.error} (tentativa ${state.retryCount})`;
}
}
// Seguro: não dá pra acessar .data quando status é "loading"
// const x: RequestState<User> = { status: "loading" };
// x.data; // Error: Property 'data' does not exist
Exhaustive Check com Never
// Função helper pra exhaustive check
function assertNever(value: never): never {
throw new Error(`Caso não tratado: ${JSON.stringify(value)}`);
}
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function calcularArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// Se adicionar um novo kind e esquecer o case,
// essa linha dá erro de compilação!
return assertNever(shape);
}
}
// Se amanhã alguém adicionar { kind: "pentagon"; ... }
// sem tratar no switch, o TypeScript acusa:
// Error: Argument of type '{ kind: "pentagon"; }'
// is not assignable to parameter of type 'never'
Result Type: Sucesso ou Erro Tipado
// Result genérico inspirado em Rust
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
// Funções helper
function Ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function Err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// Uso prático
function dividir(a: number, b: number): Result<number, string> {
if (b === 0) return Err("Divisão por zero");
return Ok(a / b);
}
const resultado = dividir(10, 3);
if (resultado.ok) {
// TypeScript sabe: resultado.value é number
console.log(`Resultado: ${resultado.value.toFixed(2)}`);
} else {
// TypeScript sabe: resultado.error é string
console.log(`Erro: ${resultado.error}`);
}
// Encadeando operações
function processarCalculo(input: string): Result<string, string> {
const num = Number(input);
if (isNaN(num)) return Err("Input não é número");
const div = dividir(100, num);
if (!div.ok) return div; // propaga o erro
return Ok(`Resultado: ${div.value}`);
}
State Machine Tipada
// Estados de um pedido
type OrderState =
| { status: "draft"; items: string[] }
| { status: "confirmed"; items: string[]; confirmedAt: Date }
| { status: "shipped"; items: string[]; confirmedAt: Date; trackingCode: string }
| { status: "delivered"; items: string[]; confirmedAt: Date; trackingCode: string; deliveredAt: Date }
| { status: "cancelled"; reason: string };
// Transições válidas (tipadas!)
function confirmar(order: Extract<OrderState, { status: "draft" }>): Extract<OrderState, { status: "confirmed" }> {
return {
...order,
status: "confirmed",
confirmedAt: new Date(),
};
}
function enviar(
order: Extract<OrderState, { status: "confirmed" }>,
trackingCode: string
): Extract<OrderState, { status: "shipped" }> {
return {
...order,
status: "shipped",
trackingCode,
};
}
function cancelar(order: OrderState, reason: string): Extract<OrderState, { status: "cancelled" }> {
return { status: "cancelled", reason };
}
// Uso
let pedido: OrderState = { status: "draft", items: ["Camiseta", "Boné"] };
if (pedido.status === "draft") {
pedido = confirmar(pedido); // OK
}
if (pedido.status === "confirmed") {
pedido = enviar(pedido, "BR123456"); // OK
}
// confirmar(pedido) quando status é "shipped" = Error!
// TypeScript impede transições inválidas
Actions de Redux/Reducer Tipadas
// Actions como discriminated union
type Action =
| { type: "ADD_TODO"; payload: { text: string } }
| { type: "TOGGLE_TODO"; payload: { id: number } }
| { type: "DELETE_TODO"; payload: { id: number } }
| { type: "CLEAR_COMPLETED" };
interface Todo {
id: number;
text: string;
done: boolean;
}
interface State {
todos: Todo[];
nextId: number;
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "ADD_TODO":
return {
...state,
todos: [...state.todos, {
id: state.nextId,
text: action.payload.text, // tipado: { text: string }
done: false,
}],
nextId: state.nextId + 1,
};
case "TOGGLE_TODO":
return {
...state,
todos: state.todos.map(t =>
t.id === action.payload.id // tipado: { id: number }
? { ...t, done: !t.done }
: t
),
};
case "DELETE_TODO":
return {
...state,
todos: state.todos.filter(t => t.id !== action.payload.id),
};
case "CLEAR_COMPLETED":
return {
...state,
todos: state.todos.filter(t => !t.done),
};
}
}
Cada exemplo mostra o mesmo princípio: o discriminante (status, kind, type, ok) é a chave que destrava os campos corretos. O TypeScript faz o trabalho pesado de verificar que você não acessa campos errados no estado errado.
Erros Comuns com Discriminated Unions
Armadilhas que comprometem suas unions
Discriminante não-literal: se o discriminante é string em vez de 'success' | 'error', o TypeScript não consegue fazer narrowing. Use sempre valores literais: string constants, não string genérica.
Esquecer o exhaustive check: sem o default com assertNever, você pode adicionar uma variante nova e esquecer de tratar. O código compila mas não trata o caso. Sempre coloque assertNever no default.
Duplicar campos entre variantes: se todas as variantes têm o campo data, mesmo quando não faz sentido (data: null no estado loading), você perdeu a vantagem. Cada variante deve ter só os campos que precisa.
Misturar discriminated unions com booleans: type State = { loading: boolean; error: boolean; data: any } cria 4 combinações possíveis, incluindo { loading: true, error: true, data: null } que não faz sentido. Discriminated unions eliminam estados impossíveis.
Não usar Extract pra transições: se sua função de transição aceita OrderState genérico em vez de Extract<OrderState, { status: 'draft' }>, qualquer estado entra. Use Extract pra restringir quais estados são válidos como input.
Checklist de Discriminated Unions
TypeScript Avançado na Prática
Discriminated unions são a base de código TypeScript robusto. Modelar estados, actions e resultados com unions tipadas elimina categorias inteiras de bugs. Mas o poder real aparece quando você combina isso com generics, conditional types e patterns como Repository e Strategy num projeto completo. No CrazyStack, você constrói um SaaS inteiro com Node.js, React e TypeScript aplicando esses padrões em cenários de produção.
Se você quer parar de debugar estados impossíveis e começar a deixar o compilador impedir que eles existam, esse é o próximo passo.
Continue lendo
Como Criar Type Guard no TypeScript
A criar type guards customizados com a keyword is e narrowing de tipos.
Como Usar Infer no TypeScript
Extraia tipos automaticamente com a keyword infer dentro de conditional types.
Como Resolver Erros no TypeScript
Entenda e resolva os erros mais comuns do compilador TypeScript.
Interface no TypeScript
Quando usar interface