Pular para o conteúdo
TypeScript

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.

  1. Passo 1 - Defina o discriminante: Escolha uma propriedade comum (type, status, kind) com valores literais diferentes em cada variante. Essa propriedade é a chave que o TypeScript usa pra diferenciar os tipos.
  2. Passo 2 - Crie cada variante com campos exclusivos: Cada variante da union tem o discriminante fixo e os campos que fazem sentido pra aquele estado. Estado 'loading' não tem data. Estado 'success' não tem error.
  3. Passo 3 - Use switch/case pro narrowing: Dentro de cada case, o TypeScript estreita o tipo automaticamente. case 'success': acessa .data sem erro. case 'error': acessa .error sem erro.
  4. Passo 4 - Implemente exhaustive check com never: No default do switch, atribua o valor a uma variável do tipo never. Se você esqueceu algum case, o TypeScript acusa erro porque o tipo não foi estreitado até never.
  5. Passo 5 - Modele state machines: Defina quais transições são válidas entre estados. Use discriminated unions pra garantir que só transições permitidas compilam.
  6. Passo 6 - Combine com generics: Crie discriminated unions genéricas: type Result = { ok: true; value: T } | { ok: false; error: E }. Reutilizável em qualquer contexto.

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, qualquer estado entra. Use Extract pra restringir quais estados são válidos como input.

Checklist de Discriminated Unions

  • Discriminante usa valores literais, não string genérica
  • Cada variante tem apenas os campos relevantes pro seu estado
  • switch/case com assertNever no default (exhaustive check)
  • Nenhum estado impossível pode ser representado pela union
  • Transições de state machine usam Extract pra restringir input
  • Result usado pra operações que podem falhar
  • Actions de reducer tipadas como discriminated union
  • Sem booleans conflitantes (isLoading + hasError) onde union resolve melhor

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.

Perguntas frequentes

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.

Como Usar Discriminated Unions Passo a Passo

Do conceito à state machine completa, cada passo adiciona uma camada de segurança.