Discriminated unions: modelando estados de forma segura

1. Introdução às Discriminated Unions

Discriminated unions (uniões discriminadas) são um dos padrões mais poderosos do sistema de tipos do TypeScript. Elas permitem modelar estados mutuamente exclusivos de forma segura, eliminando estados impossíveis em tempo de compilação.

Diferentemente de uniões simples (string | number), uniões discriminadas possuem um campo literal comum — o discriminador — que permite ao TypeScript realizar type narrowing automático. Esse campo geralmente é nomeado como kind, type ou status e contém valores literais (como strings ou números).

// União simples: sem discriminador
type Result = string | { error: string };

// União discriminada: com campo 'kind'
type ApiState = 
  | { kind: 'idle' }
  | { kind: 'loading' }
  | { kind: 'success'; data: string }
  | { kind: 'error'; message: string };

O campo kind funciona como uma "etiqueta" que o compilador usa para restringir automaticamente o tipo quando fazemos verificações.

2. Sintaxe Básica e Type Narrowing

A sintaxe básica envolve declarar um tipo união onde cada membro compartilha uma propriedade literal. O TypeScript então usa essa propriedade para realizar narrowing em estruturas como switch e if.

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; sideLength: number }
  | { kind: 'triangle'; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.sideLength ** 2;
    case 'triangle':
      return (shape.base * shape.height) / 2;
  }
}

Observe como dentro de cada case, o TypeScript sabe exatamente quais propriedades estão disponíveis — radius para circle, sideLength para square, etc. Sem discriminated unions, precisaríamos de verificações manuais e arriscaríamos acessar propriedades inexistentes.

3. Modelando Estados de Requisição (Request State)

Um caso de uso clássico é modelar o ciclo de vida de uma requisição assíncrona. Sem discriminated unions, é comum ter estados impossíveis como data e error preenchidos simultaneamente.

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function handleRequest<T>(state: RequestState<T>): string {
  if (state.status === 'idle') return 'Aguardando...';
  if (state.status === 'loading') return 'Carregando...';
  if (state.status === 'success') return `Dados: ${state.data}`;
  if (state.status === 'error') return `Erro: ${state.error}`;
  return 'Estado desconhecido';
}

Os benefícios são claros:
- Eliminação de estados impossíveis: não podemos ter data e error ao mesmo tempo
- Narrowing automático: ao verificar status, as propriedades corretas ficam disponíveis
- Documentação explícita: todos os estados possíveis ficam visíveis no tipo

4. Padrões Avançados com Discriminated Unions

Verificação de exaustão com never

Para garantir que todos os casos sejam tratados, usamos never em um caso default:

type Action =
  | { type: 'ADD_TODO'; text: string }
  | { type: 'REMOVE_TODO'; id: number }
  | { type: 'TOGGLE_TODO'; id: number };

function reducer(state: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: Date.now(), text: action.text, done: false }];
    case 'REMOVE_TODO':
      return state.filter(t => t.id !== action.id);
    case 'TOGGLE_TODO':
      return state.map(t => t.id === action.id ? { ...t, done: !t.done } : t);
    default:
      const _exhaustive: never = action;
      return state;
  }
}

Se um novo tipo de ação for adicionado sem ser tratado, o TypeScript emitirá um erro de compilação.

Uniões aninhadas

Podemos compor estados complexos combinando discriminated unions:

type AuthState =
  | { status: 'unauthenticated' }
  | { status: 'authenticated'; user: User; session: Session }
  | { status: 'twoFactorRequired'; user: User; challenge: string };

type AppState = {
  auth: AuthState;
  data: RequestState<DashboardData>;
};

5. Discriminated Unions vs. Enums + Interfaces

Enquanto enums tradicionais e interfaces separadas podem modelar estados similares, discriminated unions oferecem vantagens significativas:

// Abordagem com enum + interfaces (menos segura)
enum Status { Idle, Loading, Success, Error }
interface State {
  status: Status;
  data?: string;
  error?: string;
}

// Abordagem com discriminated union (mais segura)
type SafeState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: string }
  | { status: 'error'; error: string };

Vantagens das discriminated unions:
- Imutabilidade estrutural: cada estado tem exatamente as propriedades que precisa
- Segurança em tempo de compilação: impossível acessar data em estado loading
- Narrowing automático sem type guards manuais

Quando evitar:
- Casos muito simples com apenas 2-3 estados sem dados associados
- Quando o desempenho em tempo de execução é crítico (discriminated unions têm custo de verificação mínimo, mas existente)

6. Casos de Uso Reais no Mundo TypeScript

Redux Reducers

Discriminated unions são a base para tipagem segura de actions no Redux:

type TodoAction =
  | { type: 'ADD_TODO'; payload: { text: string } }
  | { type: 'TOGGLE_TODO'; payload: { id: number } }
  | { type: 'DELETE_TODO'; payload: { id: number } };

function todoReducer(state: Todo[], action: TodoAction): Todo[] {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: Date.now(), text: action.payload.text, done: false }];
    case 'TOGGLE_TODO':
      return state.map(t => 
        t.id === action.payload.id ? { ...t, done: !t.done } : t
      );
    case 'DELETE_TODO':
      return state.filter(t => t.id !== action.payload.id);
  }
}

Máquinas de Estado Finito (FSM)

type LightState =
  | { state: 'red'; timer: number }
  | { state: 'yellow'; timer: number }
  | { state: 'green'; timer: number };

function nextLight(current: LightState): LightState {
  switch (current.state) {
    case 'red': return { state: 'green', timer: 30 };
    case 'yellow': return { state: 'red', timer: 60 };
    case 'green': return { state: 'yellow', timer: 5 };
  }
}

7. Boas Práticas e Armadilhas Comuns

Nomeação consistente do discriminador

Escolha um nome e mantenha-o consistente em todo o projeto. kind, type, status e state são convenções comuns.

Cuidado com uniões grandes demais

Se uma união discriminada tiver mais de 8-10 membros, considere refatorar em submáquinas:

// Em vez de:
type MegaFormState = /* 15 estados diferentes */;

// Prefira:
type FormStep = 'personal' | 'address' | 'payment' | 'confirmation';
type PersonalDataState = /* ... */;
type AddressState = /* ... */;
// ...

Uso de satisfies e as const

Para garantir que objetos literais sejam tratados como tipos literais:

const config = {
  kind: 'circle',
  radius: 10
} as const satisfies Shape;

8. Conclusão e Próximos Passos

Discriminated unions transformam a forma como modelamos estados em TypeScript, oferecendo segurança de tipos que elimina classes inteiras de bugs. Os principais benefícios são:

  • Segurança: estados impossíveis são eliminados em tempo de compilação
  • Legibilidade: a intenção do código fica explícita na definição dos tipos
  • Manutenibilidade: adicionar novos estados gera erros onde precisam ser tratados

Para aprofundar, explore temas vizinhos como branded types (para criar tipos nominais) e o builder pattern (para construir discriminated unions complexas). Como exercício prático, tente modelar um formulário multi-etapas com validação por etapa usando discriminated unions — você descobrirá como o padrão simplifica drasticamente o gerenciamento de estados.

Referências