Union, Intersection e Narrowing em TypeScript

1. Visão geral

Union e intersection são duas ferramentas fundamentais para TypeScript descrever dados de forma mais flexível do que um simples interface. Narrowing é uma técnica que ajuda o compilador a “entender” o tipo específico de um valor em cada ramo do código, ao invés de forçá-lo a fazer uma conversão de tipo (as) manual.

Este artigo percorre desde os conceitos de union/intersection, passando por técnicas de narrowing comuns (typeof, instanceof, in, discriminated union), até como escrever type guards customizados. Depois de ler, você saberá quando usar union, quando usar intersection, e evitará erros como Object is possibly undefined ou as any espalhado pelo código.

2. Union type

Union (|) significa que um valor pode ser um de vários tipos.

type PaymentMethod = "cash" | "card" | "e-wallet";

function describe(method: PaymentMethod): string {
  return `Pagamento usando: ${method}`;
}

describe("card"); // OK
// describe("paypal"); // Erro de compilação: não está no union

PaymentMethod aceita apenas 3 strings literais específicas. Se você passar um valor diferente, TypeScript reporta um erro no momento da compilação, não precisa esperar até o runtime.

Union também se aplica a tipos de objeto:

interface CashPayment {
  type: "cash";
  amount: number;
}

interface CardPayment {
  type: "card";
  amount: number;
  cardNumber: string;
}

type Payment = CashPayment | CardPayment;

Neste caso, uma variável do tipo Payment pode ser CashPayment ou CardPayment, mas TypeScript só permite que você acesse os campos comuns dos dois (aqui são type e amount) se ainda não fez narrowing.

function printAmount(p: Payment) {
  console.log(p.amount); // OK, campo comum
  // console.log(p.cardNumber); // Erro: não existe em CashPayment
}

Nota: Union não mescla os campos juntos. Para acessar campos específicos, você precisa fazer narrowing primeiro (veja a seção 4).

3. Intersection type

Intersection (&) significa que um valor deve satisfazer todos os tipos combinados ao mesmo tempo.

interface Identifiable {
  id: string;
}

interface Timestamped {
  createdAt: Date;
}

type Order = Identifiable & Timestamped & {
  customerName: string;
  total: number;
};

const order: Order = {
  id: "ORD-001",
  createdAt: new Date(),
  customerName: "Nguyễn Văn A",
  total: 150_000,
};

Order aqui tem todos os campos de Identifiable, Timestamped e o objeto literal adicionado. Se faltar qualquer campo, o compilador reportará um erro.

Intersection é útil quando você quer “misturar” vários mixins pequenos em um tipo grande, similar à composição em OOP, mas em nível de tipo.

type AuditInfo = { updatedBy: string };

type AuditedOrder = Order & AuditInfo;

const audited: AuditedOrder = {
  ...order,
  updatedBy: "admin",
};

3.1. Intersection com tipos primitivos conflitantes

Se dois tipos em uma intersection conflitam sobre um tipo primitivo, o resultado é never:

type Impossible = string & number; // tipo never, nenhum valor satisfaz isso

Este caso geralmente é um sinal de que você digitou & por engano em vez de |.

4. Narrowing: estreitando tipos em cada ramo do código

Narrowing é o processo em que o compilador automaticamente infere um tipo mais específico baseado em condições de verificação no código (if, switch, typeof…).

4.1. Narrowing com typeof

Use para unions que consistem em tipos primitivos.

function formatId(id: string | number): string {
  if (typeof id === "number") {
    return id.toFixed(0); // id é estreitado para number
  }
  return id.toUpperCase(); // id é estreitado para string
}

Dentro do ramo if, TypeScript sabe que id é number, então permite chamar .toFixed(). No ramo else (implícito), id é apenas string.

4.2. Narrowing com instanceof

Use para unions que consistem em classes.

class ValidationError extends Error {
  constructor(public field: string) {
    super(`Field ${field} is invalid`);
  }
}

function handleError(err: Error | ValidationError): void {
  if (err instanceof ValidationError) {
    console.log(`Erro de validação no campo: ${err.field}`);
  } else {
    console.log(`Outro erro: ${err.message}`);
  }
}

err.field existe apenas em ValidationError, então o compilador só permite acesso no ramo que foi verificado com instanceof.

4.3. Narrowing com in

Use quando tipos em uma union se distinguem por campos específicos (sem um campo discriminante comum).

interface Dog {
  bark: () => void;
}

interface Cat {
  meow: () => void;
}

function makeSound(pet: Dog | Cat): void {
  if ("bark" in pet) {
    pet.bark(); // estreitado para Dog
  } else {
    pet.meow(); // estreitado para Cat
  }
}

O operador in verifica se a propriedade existe no objeto em tempo de execução, e o compilador usa esse resultado para estreitar o tipo correspondente.

4.4. Discriminated union — a forma mais segura

Este é o padrão mais comum em código real: adicionar um campo literal compartilhado (geralmente chamado de kind ou type) para distinguir os “ramos” em uma union.

interface SuccessResult {
  status: "success";
  data: string;
}

interface ErrorResult {
  status: "error";
  message: string;
}

type ApiResult = SuccessResult | ErrorResult;

function handleResult(result: ApiResult): void {
  switch (result.status) {
    case "success":
      console.log(`Dados: ${result.data}`);
      break;
    case "error":
      console.log(`Erro: ${result.message}`);
      break;
  }
}

O compilador usa o campo status como “discriminante”: no case "success", result é estreitado para SuccessResult; no case "error", é estreitado para ErrorResult. Esta é a forma que TypeScript recomenda para qualquer union com mais de 2 ramos, pois switch também suporta verificação de exhaustiveness (seção 4.6).

4.5. Narrowing com truthiness e optional chaining

interface Customer {
  name: string;
  phone?: string;
}

function callCustomer(customer: Customer): void {
  if (customer.phone) {
    // customer.phone é estreitado de string | undefined para string
    console.log(`Ligando para: ${customer.phone}`);
  } else {
    console.log("Cliente não tem número de telefone");
  }
}

Uma verificação de truthiness (if (customer.phone)) elimina undefined (e strings vazias, mas com números de telefone isso não importa).

4.6. Verificação de exhaustiveness com never

Quando você adiciona um novo ramo à union mas esquece de tratá-lo, você quer que o compilador avise imediatamente ao invés de ter um bug silencioso em tempo de execução. Use uma variável do tipo never no ramo default:

function assertUnreachable(x: never): never {
  throw new Error(`Caso não tratado: ${JSON.stringify(x)}`);
}

function handleResultSafe(result: ApiResult): string {
  switch (result.status) {
    case "success":
      return result.data;
    case "error":
      return result.message;
    default:
      return assertUnreachable(result); // se adicionar um novo ramo e esquecer de tratá-lo, erro de compilação aqui
  }
}

Se depois alguém adicionar PendingResult a ApiResult mas esquecer de adicionar o case correspondente, result no ramo default não será mais never, e o compilador reportará um erro na compilação — sem precisar esperar pelo bug aparecer em produção.

5. User-defined type guard

Quando a lógica de verificação é mais complexa do que uma expressão simples, você pode escrever uma função que retorna o tipo x is T para reutilizar narrowing.

interface PaidOrder {
  status: "paid";
  paidAt: Date;
}

interface PendingOrder {
  status: "pending";
}

type OrderState = PaidOrder | PendingOrder;

function isPaid(order: OrderState): order is PaidOrder {
  return order.status === "paid";
}

function printReceipt(order: OrderState): void {
  if (isPaid(order)) {
    console.log(`Pagamento realizado em: ${order.paidAt.toISOString()}`);
  } else {
    console.log("Ainda não foi pago");
  }
}

order is PaidOrder é um type predicate. O compilador confia no resultado da função isPaid para estreitar order para PaidOrder no ramo if, mesmo quando a lógica de verificação está em uma função separada.

6. Verificação com testes

Como narrowing é uma funcionalidade de tempo de compilação, “testar” é realmente executar tsc sem erros. Mas você ainda deve escrever testes para o comportamento em tempo de execução correspondente.

npm install -D vitest typescript
import { describe, it, expect } from "vitest";

function totalAmount(payment: CashPayment | CardPayment): number {
  return payment.amount;
}

describe("Payment narrowing", () => {
  it("calcula o amount correto para pagamento em dinheiro", () => {
    const cash: CashPayment = { type: "cash", amount: 50_000 };
    expect(totalAmount(cash)).toBe(50_000);
  });

  it("calcula o amount correto para pagamento com cartão", () => {
    const card: CardPayment = {
      type: "card",
      amount: 200_000,
      cardNumber: "4111111111111111",
    };
    expect(totalAmount(card)).toBe(200_000);
  });
});

Executar npx vitest run produz:

✓ Payment narrowing > calcula o amount correto para pagamento em dinheiro
✓ Payment narrowing > calcula o amount correto para pagamento com cartão

Arquivos de teste  1 aprovado (1)
     Testes  2 aprovados (2)

7. Erros comuns e quando não usar

  • Abuso de as para evitar narrowing: se você se vê fazendo conversão de tipo (as SomeType) continuamente para “agradar” o compilador, é provável que seu union/type guard esteja mal desenhado. Volte e use discriminated union.
  • Intersection de duas interfaces com campos de mesmo nome mas tipos diferentes: TypeScript tentará mesclar, mas se os tipos entrarem em conflito (por exemplo, um é string e outro é number), esse campo se torna never — muito fácil de causar erros confusos. Sempre use nomes de campos consistentes ao projetar mixins.
  • Union muito ampla (por exemplo, string | number | boolean | object): força narrowing em todos os lugares que usam o tipo, deixando o código bagunçado. Se uma union tiver mais de 4–5 ramos não relacionados, revise seu domain model — você pode precisar separá-la em várias funções/overloads.
  • Esquecer default com never no switch: perde a vantagem da verificação de exhaustiveness, fácil esquecer um ramo ao expandir uma union.
  • Operador in em objetos com prototype chain complexa: in verifica até propriedades herdadas, pode dar resultados inesperados de narrowing com classes com herança profunda. Nesse caso, instanceof ou um campo discriminante é mais confiável.

Nota: Narrowing funciona bem apenas quando TypeScript pode provar por fluxo de controle estático. Se você atribuir um valor já estreitado a outra variável através de uma função callback assíncrona (setTimeout, promise .then), o tipo estreitado pode ser “esquecido” — TypeScript não rastreia através de limites de closure de forma absoluta em todos os casos.

8. Conclusão

Union descreve “um de vários tipos”, intersection descreve “todos os tipos ao mesmo tempo” — essas duas ferramentas são a base para construir um domain model preciso em TypeScript. Narrowing (typeof, instanceof, in, discriminated union, type guard customizado) o ajuda a explorar unions de forma segura sem precisar forçar conversões de tipo manualmente.

Regra prática: prefira discriminated union quando tiver 2 ou mais ramos, sempre adicione verificação de exhaustiveness com never no switch, e evite intersection entre tipos com campos de mesmo nome mas tipos diferentes. Aplicado corretamente, você pegará muitos erros de lógica no momento da escrita do código, antes que ele chegue à produção.

typescripttype-safetyjavascriptfrontend