Kaique Mitsuo Silva Yamamoto
Arquitetura softwareFrontend webTypeScriptClean Code

Discriminated Unions em TypeScript

O padrão mais poderoso de TypeScript para modelar estados, variantes e resultados — exemplos full-stack com React, NestJS e Express.

Discriminated Unions

Discriminated unions são o padrão mais importante de TypeScript para modelar dados que podem ter formas diferentes. São tão fundamentais que, uma vez dominados, você vai usá-los em literalmente todo projeto.

O conceito

Uma discriminated union é um tipo que pode ser uma de várias variantes, onde cada variante tem uma propriedade discriminante (comum a todas) que permite ao TypeScript saber em qual variante estamos.

// A propriedade "kind" é o discriminante
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle'; base: number; height: number };

Quando você verifica shape.kind === 'circle', o TypeScript estreita o tipo automaticamente — e só permite acessar radius, não width nem height.

Analogia do dia a dia

Pense em um formulário de entrega:

  • Se o tipo de entrega é "correios", precisa de CEP e código de rastreio
  • Se é "retirada", precisa de data e horário
  • Se é "motoboy", precisa de endereço e referência

Cada tipo de entrega tem campos diferentes. Mas todos são "entrega". A discriminated union garante que, quando você sabe que é "correios", o TypeScript sabe que cep existe.

Exemplo prático — Frontend (React)

Estado de requisição API

// Definição dos estados possíveis
type ApiState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string; statusCode?: number };

// Componente que renderiza baseado no estado
function ProductList() {
  const [state, setState] = useState<ApiState<Product[]>>({ status: 'idle' });

  const loadProducts = async () => {
    setState({ status: 'loading' });

    try {
      const res = await fetch('/api/products');
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      setState({ status: 'success', data });
    } catch (err) {
      setState({
        status: 'error',
        error: err instanceof Error ? err.message : 'Erro desconhecido',
      });
    }
  };

  // ✅ O TS sabe exatamente quais propriedades existem em cada caso
  switch (state.status) {
    case 'idle':
      return <button onClick={loadProducts}>Carregar produtos</button>;

    case 'loading':
      return <div className="spinner">Carregando...</div>;

    case 'success':
      // ✅ TS sabe que state.data existe e é Product[]
      return (
        <ul>
          {state.data.map(p => <li key={p.id}>{p.name} — R$ {p.price}</li>)}
        </ul>
      );

    case 'error':
      // ✅ TS sabe que state.error existe e é string
      return (
        <div className="error">
          <p>Erro: {state.error}</p>
          <button onClick={loadProducts}>Tentar novamente</button>
        </div>
      );
  }
}

Formulário com múltiplos métodos de pagamento

// Cada método de pagamento tem campos diferentes
type PaymentMethod =
  | { type: 'credit_card'; cardNumber: string; expiry: string; cvv: string }
  | { type: 'pix'; cpf: string }
  | { type: 'boleto'; cpf: string; fullName: string }
  | { type: 'crypto'; walletAddress: string; network: 'ethereum' | 'bitcoin' };

function PaymentForm() {
  const [method, setMethod] = useState<PaymentMethod>({ type: 'credit_card', cardNumber: '', expiry: '', cvv: '' });

  const handleSubmit = () => {
    // ✅ TS garante que só acessa campos que existem para cada tipo
    switch (method.type) {
      case 'credit_card':
        processCreditCard(method.cardNumber, method.expiry, method.cvv);
        break;
      case 'pix':
        generatePixQR(method.cpf);
        break;
      case 'boleto':
        generateBoleto(method.cpf, method.fullName);
        break;
      case 'crypto':
        sendCrypto(method.walletAddress, method.network);
        break;
    }
  };

  // ✅ TS sabe que os campos mudam conforme o type
  return (
    <form onSubmit={handleSubmit}>
      {method.type === 'credit_card' && (
        <>
          <input value={method.cardNumber} onChange={e => setMethod({ ...method, cardNumber: e.target.value })} />
          {/* TS só aceita setMethod com cardNumber, expiry, cvv — não cpf ou walletAddress */}
        </>
      )}
      {method.type === 'pix' && (
        <input value={method.cpf} onChange={e => setMethod({ ...method, cpf: e.target.value })} />
      )}
    </form>
  );
}

Exemplo prático — Backend (NestJS)

Result type para operações de negócio

type OrderResult =
  | { ok: true; order: Order }
  | { ok: false; error: 'INSUFFICIENT_STOCK'; available: number }
  | { ok: false; error: 'PAYMENT_FAILED'; reason: string }
  | { ok: false; error: 'USER_BLOCKED'; blockedUntil: Date };

class OrderService {
  async createOrder(dto: CreateOrderDto): Promise<OrderResult> {
    const product = await this.productRepo.findById(dto.productId);

    if (product.stock < dto.quantity) {
      return { ok: false, error: 'INSUFFICIENT_STOCK', available: product.stock };
    }

    const user = await this.userRepo.findById(dto.userId);
    if (user.isBlocked) {
      return { ok: false, error: 'USER_BLOCKED', blockedUntil: user.blockedUntil };
    }

    const payment = await this.paymentService.charge(dto.paymentMethod, dto.total);
    if (!payment.success) {
      return { ok: false, error: 'PAYMENT_FAILED', reason: payment.failureReason };
    }

    const order = await this.orderRepo.create(dto);
    return { ok: true, order };
  }
}

// Controller — trata cada caso com mensagem específica
class OrderController {
  async create(req: Request, res: Response) {
    const result = await this.orderService.createOrder(req.body);

    if (result.ok) {
      return res.status(201).json(result.order);
    }

    // ✅ TS sabe quais propriedades existem em cada erro
    switch (result.error) {
      case 'INSUFFICIENT_STOCK':
        return res.status(409).json({
          error: 'Estoque insuficiente',
          available: result.available, // ✅ TS sabe que existe
        });
      case 'PAYMENT_FAILED':
        return res.status(402).json({
          error: 'Pagamento recusado',
          reason: result.reason, // ✅ TS sabe que existe
        });
      case 'USER_BLOCKED':
        return res.status(403).json({
          error: 'Usuário bloqueado',
          blockedUntil: result.blockedUntil, // ✅ TS sabe que existe
        });
    }
  }
}

Exemplo prático — Backend (Express)

Event system com discriminated unions

type AppEvent =
  | { type: 'USER_REGISTERED'; userId: string; email: string; timestamp: Date }
  | { type: 'ORDER_PLACED'; orderId: string; userId: string; total: number; timestamp: Date }
  | { type: 'PAYMENT_RECEIVED'; paymentId: string; orderId: string; amount: number; timestamp: Date }
  | { type: 'ORDER_SHIPPED'; orderId: string; trackingCode: string; timestamp: Date }
  | { type: 'SUBSCRIPTION_CANCELLED'; subscriptionId: string; reason: string; timestamp: Date };

// Handler — cada evento é tratado com campos específicos
function handleEvent(event: AppEvent) {
  switch (event.type) {
    case 'USER_REGISTERED':
      emailService.sendWelcome(event.email);           // ✅ event.email existe
      analytics.track('signup', { userId: event.userId });
      break;

    case 'ORDER_PLACED':
      inventoryService.reserve(event.orderId);          // ✅ event.orderId existe
      analytics.track('purchase', { total: event.total });
      break;

    case 'PAYMENT_RECEIVED':
      orderService.markAsPaid(event.orderId);            // ✅ event.orderId existe
      receiptService.generate(event.paymentId);
      break;

    case 'ORDER_SHIPPED':
      notificationService.sendTracking(event.orderId, event.trackingCode); // ✅ event.trackingCode existe
      break;

    case 'SUBSCRIPTION_CANCELLED':
      churnAnalysis.record(event.reason);               // ✅ event.reason existe
      winBackCampaign.schedule(event.subscriptionId);
      break;
  }
}

O poder do never — garantir exhaustiveness

TypeScript pode verificar se você tratou todos os casos de uma union. Basta usar never:

function assertNever(value: never): never {
  throw new Error(`Valor inesperado: ${JSON.stringify(value)}`);
}

function handleEvent(event: AppEvent) {
  switch (event.type) {
    case 'USER_REGISTERED': /* ... */ break;
    case 'ORDER_PLACED': /* ... */ break;
    // ❌ Se eu esquecer de tratar 'PAYMENT_RECEIVED', o TS dá erro:
    // Argument of type '{ type: "PAYMENT_RECEIVED"; ... }' is not assignable to parameter of type 'never'
    default:
      assertNever(event); // ✅ TS garante que todos os casos foram tratados
  }
}

Resultado: Se alguém adicionar um novo tipo de evento à union, o compilador vai apontar todos os lugares que precisam ser atualizados.

Discriminated Union vs Herança

HerançaDiscriminated Union
Adicionar varianteCriar subclasseAdicionar `
Verificar qual varianteinstanceof (acoplamento)switch no discriminante
Exhaustiveness check❌ Não✅ Sim (com never)
Serialização para JSON❌ Perde classe✅ Objeto puro
PadrãoOpen for extensionClosed + exhaustive

Em TypeScript, discriminated unions são quase sempre preferíveis a herança para modelar dados.

Referências

On this page