Union discriminada no TypeScript para estado e resposta de API

1. Visão geral

Muitos erros de interface vêm de estados que nunca deveriam acontecer. Por exemplo, isLoading sendo true ao mesmo tempo que error tem um valor. Ou data sendo undefined apesar da requisição ter relatado sucesso.

Este artigo o guia para usar union discriminada do TypeScript para modelar estado de carregamento de dados e resposta da API. Depois de ler, você saberá como:

  • definir uma union com um campo discriminante,
  • deixar o compilador estreitar automaticamente o tipo (narrowing),
  • forçar o tratamento de todos os ramos com exhaustiveness check,
  • validar resposta em tempo de execução com Zod,
  • escrever testes para as transições de estado.

2. Problema com “saco de campos opcionais”

A maneira costumeira é agrupar tudo em uma interface com campos opcionais:

interface OrderState {
  isLoading: boolean;
  error?: string;
  order?: { id: string; total: number };
}

const state: OrderState = {
  isLoading: true,
  error: "Timeout",
  order: { id: "A1", total: 120 },
};

O código acima compila normalmente. Porém, ele descreve um estado sem sentido: carregando ao mesmo tempo, com erro, e com dados.

Com três campos assim, você tem até 2 × 2 × 2 = 8 combinações. Na verdade, apenas cerca de 4 combinações são válidas. Ao ler o estado, você também precisa escrever state.order?.total ou state.order! por toda parte. Quando há erros, o compilador não pode ajudar em nada.

3. O que é union discriminada

Union discriminada é uma union de vários tipos de objeto. Cada tipo tem um campo em comum com um literal type diferente. Este campo é chamado de discriminante ou “tag”:

type Order = { id: string; total: number };

type OrderState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; order: Order }
  | { status: "error"; message: string };

Aqui status é o discriminante. Cada ramo declara apenas os campos que fazem sentido para ele. O ramo success tem order, o ramo error tem message. O estado “carregando e com erro ao mesmo tempo” agora não pode ser representado:

const bad: OrderState = { status: "loading", message: "Timeout" };
error TS2353: Um literal de objeto pode especificar apenas propriedades conhecidas,
e 'message' não existe no tipo '{ status: "loading"; }'.

3.1. Narrowing pelo discriminante

Quando você verifica o valor do discriminante, TypeScript estreita automaticamente o tipo no ramo correspondente:

function describe(state: OrderState): string {
  if (state.status === "success") {
    return `Pedido ${state.order.id}: ${state.order.total}R$`;
  }
  if (state.status === "error") {
    return `Erro: ${state.message}`;
  }
  return state.status === "loading" ? "Carregando..." : "Não iniciado";
}

console.log(describe({ status: "success", order: { id: "A1", total: 120 } }));
console.log(describe({ status: "error", message: "Timeout" }));
Pedido A1: 120R$
Erro: Timeout

Dentro do ramo if (state.status === "success"), o tipo de state é { status: "success"; order: Order }. Portanto, você pode acessar state.order.id diretamente, sem precisar de ?. ou !. Se você digitar state.message neste ramo, o compilador vai relatar um erro.

4. Exhaustiveness check: não omitir estados

Uma union é realmente útil apenas quando adicionar um novo ramo faz o compilador apontar todos os lugares onde esse ramo não foi tratado. A maneira comum é usar switch combinado com o tipo never:

function assertNever(value: never): never {
  throw new Error(`Estado não tratado: ${JSON.stringify(value)}`);
}

function render(state: OrderState): string {
  switch (state.status) {
    case "idle":
      return "<button>Carregar pedido</button>";
    case "loading":
      return "<spinner />";
    case "success":
      return `<p>Total: ${state.order.total}R$</p>`;
    case "error":
      return `<p class="error">${state.message}</p>`;
    default:
      return assertNever(state);
  }
}

Depois de passar por todos os quatro cases, o tipo de state no ramo default é never. A chamada assertNever(state) é, portanto, válida.

Suponha que mais tarde você adicione o ramo { status: "cancelled" } a OrderState mas esqueça de atualizar render:

error TS2345: Argumento do tipo '{ status: "cancelled"; }'
não é atribuível ao parâmetro do tipo 'never'.

O erro aparece na hora da compilação, antes do usuário ver uma tela em branco. Se dados estranhos ainda vazarem em tempo de execução, por exemplo de uma API pouco confiável, assertNever lançará um erro claro em vez de silenciosamente retornar undefined.

Observação: Se você ativar "strict": true e declarar um tipo de retorno explícito (: string), omitir um case também causa o erro “Function lacks ending return statement”. Ainda assim, assertNever vale a pena usar. Ele dá uma mensagem de erro mais clara e funciona mesmo com funções que retornam void.

5. Modelando resposta da API

A API geralmente retorna de duas formas: sucesso ou falha. Você pode definir um tipo Result para uso comum:

type Result<T, E = ApiError> =
  | { ok: true; data: T }
  | { ok: false; error: E };

type ApiError =
  | { kind: "network"; cause: string }
  | { kind: "http"; status: number }
  | { kind: "invalid"; issues: string[] };

O discriminante não precisa ser uma string. ok: true e ok: false são dois literal types do boolean, então narrowing ainda funciona. ApiError também é uma union discriminada aninhada, distinguida por kind.

5.1. Validar resposta em tempo de execução com Zod

O tipo do TypeScript desaparece após a compilação. Se você escrever (await res.json()) as Order, você está apenas “prometendo” ao compilador, não verificando nada. Para garantir que os dados do servidor tenham a forma correta, valide com Zod:

npm install zod

Suponha que o servidor retorna um payload com seu próprio discriminante chamado type:

import { z } from "zod";

const OrderSchema = z.object({ id: z.string(), total: z.number() });

const OrderResponseSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("found"), order: OrderSchema }),
  z.object({ type: z.literal("not_found"), orderId: z.string() }),
]);

type OrderResponse = z.infer<typeof OrderResponseSchema>;

z.discriminatedUnion("type", [...]) cria um schema baseado no campo type. Zod lê o valor de type primeiro e só depois valida de acordo com o ramo correto, então mensagens de erro são mais precisas que com z.union. z.infer gera o tipo TypeScript correspondente, então você não precisa escrever o tipo duas vezes.

Em seguida, a função para chamar a API, envolvendo todos os erros em Result:

async function fetchOrder(id: string): Promise<Result<OrderResponse>> {
  let res: Response;
  try {
    res = await fetch(`/api/orders/${id}`);
  } catch (e) {
    return { ok: false, error: { kind: "network", cause: String(e) } };
  }

  if (!res.ok) {
    return { ok: false, error: { kind: "http", status: res.status } };
  }

  const parsed = OrderResponseSchema.safeParse(await res.json());
  if (!parsed.success) {
    const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
    return { ok: false, error: { kind: "invalid", issues } };
  }
  return { ok: true, data: parsed.data };
}

Esta função nunca lança um erro para fora. Toda situação é representada no tipo de retorno. safeParse em si também retorna uma union discriminada com discriminante success. Este é exatamente o padrão que você está aprendendo.

No lado de quem chama a função, trate o resultado assim:

async function showOrder(id: string): Promise<string> {
  const result = await fetchOrder(id);
  if (!result.ok) {
    switch (result.error.kind) {
      case "network": return "Conexão perdida, tente novamente mais tarde.";
      case "http":    return `O servidor retornou ${result.error.status}.`;
      case "invalid": return "Dados inválidos.";
      default:        return assertNever(result.error);
    }
  }
  return result.data.type === "found"
    ? `Total: ${result.data.order.total}R$`
    : `Pedido ${result.data.orderId} não encontrado`;
}

Após a linha if (!result.ok) { ... }, TypeScript sabe que result é definitivamente o ramo ok: true. Portanto, result.data pode ser acessado diretamente.

6. Transições de estado com reducer

Union discriminada funciona muito bem com reducer, como useReducer do React ou um store customizado. Tanto o estado quanto a ação são unions:

type OrderAction =
  | { type: "FETCH" }
  | { type: "RESOLVE"; order: Order }
  | { type: "REJECT"; message: string }
  | { type: "RESET" };

function orderReducer(state: OrderState, action: OrderAction): OrderState {
  switch (action.type) {
    case "FETCH":
      return state.status === "loading" ? state : { status: "loading" };
    case "RESOLVE":
      return state.status === "loading"
        ? { status: "success", order: action.order }
        : state;
    case "REJECT":
      return state.status === "loading"
        ? { status: "error", message: action.message }
        : state;
    case "RESET":
      return { status: "idle" };
    default:
      return assertNever(action);
  }
}

Este reducer codifica as regras de transição de estado. RESOLVE só tem efeito quando está em loading. Portanto, uma resposta que chega atrasada depois que o usuário fez RESET será ignorada. Você não precisa de nenhuma flag adicional.

7. Escrevendo testes para o comportamento

Este artigo usa Vitest. Sua sintaxe expect é similar à do AssertJ e Jest:

npm install -D vitest
import { describe, it, expect } from "vitest";
import { orderReducer, render } from "./order";

describe("orderReducer", () => {
  const order = { id: "A1", total: 120 };

  it("transição de loading para success quando RESOLVE", () => {
    const next = orderReducer({ status: "loading" }, { type: "RESOLVE", order });
    expect(next).toEqual({ status: "success", order });
  });

  it("ignora RESOLVE atrasado quando já em idle", () => {
    const next = orderReducer({ status: "idle" }, { type: "RESOLVE", order });
    expect(next).toEqual({ status: "idle" });
  });

  it("render exibe mensagem de erro", () => {
    expect(render({ status: "error", message: "Timeout" }))
      .toBe('<p class="error">Timeout</p>');
  });
});
npx vitest run
 ✓ order.test.ts (3 tests)
   ✓ orderReducer > transição de loading para success quando RESOLVE
   ✓ orderReducer > ignora RESOLVE atrasado quando já em idle
   ✓ orderReducer > render exibe mensagem de erro

 Test Files  1 passed (1)
      Tests  3 passed (3)

O segundo teste é o mais importante. Ele consolida o comportamento anti-race condition, tipo de erro que o modelo “saco de campos opcionais” facilmente encontra.

8. Erros comuns e quando não usar

Discriminante sendo “alargado” para string. Quando você cria um objeto em uma variável intermediária, TypeScript pode inferir o tipo string em vez de literal:

const s = { status: "loading" };
const state: OrderState = s;
error TS2322: Tipo '{ status: string; }' não é atribuível ao tipo 'OrderState'.

Você tem três formas de corrigir: declarar o tipo desde o início (const s: OrderState = ...), usar as const, ou usar satisfies OrderState.

Destructuring cedo perde a conexão. Se você escrever let { status, ...rest } = state, TypeScript não sabe mais a qual ramo rest pertence quando você verifica status. Narrowing por destructuring funciona apenas quando a variável é declarada com const e você não a reatribui.

Usar o operador in em vez de discriminante. Verificar "order" in state ainda funciona para narrowing. Porém, essa abordagem é frágil quando dois ramos têm o mesmo nome de campo. Sempre verifique pelo discriminante.

Discriminante opcional ou que pode ser modificado. Não declare status?: .... Também não atribua state.status = "error" a um objeto existente. Em vez disso, crie um novo objeto para cada estado. Você pode marcar o campo como readonly para o compilador manter essa regra.

Casting em vez de validar. Escrever as OrderResponse para dados da rede apagará todo o benefício acima. Nas fronteiras do sistema, sempre valide dados em tempo de execução.

Observação: Não use union discriminada quando os campos são realmente independentes. Por exemplo, um formulário com email?, phone?, note? pode ser preenchido em qualquer combinação. Nesse caso, o campo opcional é o modelo correto. Forçá-los em uma union apenas faz o número de ramos explodir sem trazer segurança adicional.

9. Conclusão

Union discriminada ajuda você a transformar estados inválidos em erros de compilação. Você viu como:

  • definir uma union com um discriminante literal,
  • deixar TypeScript estreitar automaticamente pelo discriminante,
  • usar assertNever para forçar o tratamento de todos os ramos,
  • modelar resposta da API com Result e validar dados com z.discriminatedUnion,
  • aplicar o mesmo padrão para reducers e verificar com testes.

A regra simples é: se os campos fazem sentido apenas quando aparecem juntos, coloque-os no mesmo ramo da union. Na próxima vez, quando estiver prestes a escrever isLoading ao lado de error? e data?, tente substituir por um campo status.

typescriptunion discriminadatype safetystate managementapi