Union types e intersection types no TypeScript

1. Introdução aos Union Types

Union types permitem que uma variável, parâmetro ou retorno de função aceite mais de um tipo. A sintaxe usa o operador pipe (|) para declarar que o valor pode ser de qualquer tipo listado.

// Tipo que pode ser string ou number
type ID = string | number;

// Função que aceita múltiplos tipos
function formatInput(input: string | number): string {
  return `Valor: ${input}`;
}

console.log(formatInput("abc"));  // Funciona
console.log(formatInput(123));    // Funciona
// console.log(formatInput(true)); // Erro! boolean não está no union

Union types são ideais para criar APIs flexíveis, onde um parâmetro pode receber diferentes formatos de dados.

function setPadding(padding: number | string): void {
  if (typeof padding === "number") {
    console.log(`Padding: ${padding}px`);
  } else {
    console.log(`Padding: ${padding}`);
  }
}

setPadding(10);      // "Padding: 10px"
setPadding("2rem");  // "Padding: 2rem"

2. Trabalhando com Union Types na Prática

Para manipular valores de union types, precisamos usar type narrowing — reduzir o tipo para um mais específico.

Narrowing com typeof

function processValue(value: string | number | boolean): void {
  if (typeof value === "string") {
    // Aqui value é string
    console.log(value.toUpperCase());
  } else if (typeof value === "number") {
    // Aqui value é number
    console.log(value.toFixed(2));
  } else {
    // Aqui value é boolean
    console.log(value ? "Verdadeiro" : "Falso");
  }
}

Narrowing com in operator

type Cat = { meow: () => void };
type Dog = { bark: () => void };

function makeSound(animal: Cat | Dog): void {
  if ("meow" in animal) {
    animal.meow(); // TypeScript sabe que é Cat
  } else {
    animal.bark(); // TypeScript sabe que é Dog
  }
}

Type guards personalizados

interface Fish {
  swim: () => void;
  layEggs: () => void;
}

interface Bird {
  fly: () => void;
  layEggs: () => void;
}

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

function move(pet: Fish | Bird): void {
  if (isFish(pet)) {
    pet.swim(); // TypeScript infere que é Fish
  } else {
    pet.fly(); // TypeScript infere que é Bird
  }
}

3. Intersection Types: Combinando Tipos

Intersection types usam o operador & para combinar todos os membros de cada tipo em um único tipo. É o equivalente ao "E" lógico.

interface Person {
  name: string;
  age: number;
}

interface Employee {
  employeeId: number;
  department: string;
}

// Intersection: deve ter TODAS as propriedades
type StaffMember = Person & Employee;

const staff: StaffMember = {
  name: "Alice",
  age: 30,
  employeeId: 12345,
  department: "Engineering"
};

A diferença fundamental: union é "ou" (pode ser um tipo ou outro), intersection é "e" (deve ser todos os tipos simultaneamente).

// Union: ou um ou outro
type Status = "active" | "inactive"; // Pode ser apenas um valor

// Intersection: combina todos
type Complete = { a: number } & { b: string }; // Deve ter a E b

4. Intersection Types com Interfaces e Type Aliases

Podemos combinar interfaces e type aliases existentes para criar tipos mais específicos.

interface HasName {
  name: string;
}

interface HasAge {
  age: number;
}

interface HasEmail {
  email: string;
}

// Combinando três interfaces
type User = HasName & HasAge & HasEmail;

const user: User = {
  name: "Bob",
  age: 25,
  email: "bob@example.com"
};

Cuidados com conflitos de propriedades

Quando dois tipos no intersection têm a mesma propriedade com tipos diferentes, o resultado é um never (impossível de satisfazer).

type A = { value: string };
type B = { value: number };

// value é string & number = never
type Conflict = A & B; 

// const x: Conflict = { value: "test" }; // Erro!
// const x: Conflict = { value: 123 };    // Erro!

5. Union Types com Objetos e Discriminated Unions

Discriminated unions usam uma propriedade literal comum (como type ou kind) para diferenciar objetos em um union.

interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  sideLength: number;
}

interface Triangle {
  kind: "triangle";
  base: number;
  height: number;
}

type Shape = Circle | Square | Triangle;

function calculateArea(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;
    default:
      // Exaustividade: se adicionarmos nova forma, o TypeScript avisará
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

Essa técnica é poderosa para pattern matching e garante que todos os casos sejam tratados.

6. Técnicas Avançadas de Combinação

Union com tipos literais

type Status = "pending" | "processing" | "completed" | "failed";
type HTTPCode = 200 | 201 | 400 | 401 | 500;

function getMessage(code: HTTPCode): string {
  const messages: Record<HTTPCode, string> = {
    200: "OK",
    201: "Created",
    400: "Bad Request",
    401: "Unauthorized",
    500: "Internal Server Error"
  };
  return messages[code];
}

Intersection com tipos genéricos

interface WithId {
  id: string;
}

interface WithTimestamp {
  createdAt: Date;
  updatedAt: Date;
}

type Entity<T> = T & WithId & WithTimestamp;

interface Product {
  name: string;
  price: number;
}

type ProductEntity = Entity<Product>;

const product: ProductEntity = {
  id: "abc-123",
  name: "Laptop",
  price: 1500,
  createdAt: new Date(),
  updatedAt: new Date()
};

Manipulando unions com Exclude e Extract

type AllTypes = string | number | boolean | null | undefined;

// Exclude remove tipos específicos
type NonNullableTypes = Exclude<AllTypes, null | undefined>;
// Resultado: string | number | boolean

// Extract mantém apenas os tipos especificados
type PrimitiveTypes = Extract<AllTypes, string | number | boolean>;
// Resultado: string | number | boolean

// Aplicação prática
function processValue(value: Exclude<AllTypes, null | undefined>): void {
  // value nunca será null ou undefined
  console.log(value.toString());
}

7. Boas Práticas e Armadilhas Comuns

Quando preferir union vs intersection

  • Use union quando o valor pode ser um entre vários tipos (parâmetros flexíveis, estados alternativos)
  • Use intersection quando precisa combinar todas as características de múltiplos tipos (composição de objetos)

Evitando unions muito grandes

// Ruim: union grande e difícil de manter
type Status = "a" | "b" | "c" | "d" | "e" | "f" | "g";

// Bom: agrupe por contexto
type LoadingStatus = "idle" | "loading" | "success" | "error";
type FormStatus = "draft" | "submitted" | "approved" | "rejected";

Problemas com any e unknown em combinações

// any anula o tipo seguro
type Unsafe = string | any; // Resultado: any

// unknown em intersection força verificação
type Safe = string & unknown; // Resultado: string (unknown é ignorado)
type Unsafe2 = string & any;  // Resultado: any (any prevalece)

Dicas de legibilidade com type aliases nomeados

// Ruim: difícil de ler
function process(data: string | number | boolean | null): void {}

// Bom: use aliases descritivos
type InputValue = string | number | boolean | null;
function process(data: InputValue): void {}

Referências