Type narrowing com switch(true) e pattern matching proposals

1. Introdução ao Type Narrowing em TypeScript

Type narrowing é o processo pelo qual o TypeScript reduz um tipo amplo para um tipo mais específico dentro de um bloco de código. É essencial para escrever código type-safe, permitindo que o compilador entenda exatamente qual tipo está sendo manipulado em cada contexto.

As técnicas tradicionais incluem:

// typeof narrowing
function process(value: string | number) {
  if (typeof value === "string") {
    return value.toUpperCase(); // TypeScript sabe que é string
  }
  return value.toFixed(2); // TypeScript sabe que é number
}

// instanceof narrowing
class Dog { bark() {} }
class Cat { meow() {} }

function makeSound(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    animal.bark();
  } else {
    animal.meow();
  }
}

// Discriminated unions
type Shape = 
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function area(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
  }
  return shape.side ** 2;
}

No entanto, essas técnicas mostram limitações quando enfrentamos condições complexas que não se encaixam perfeitamente em typeof, instanceof ou discriminated unions.

2. Switch(true) como Padrão de Narrowing Avançado

O padrão switch(true) explora a capacidade do TypeScript de realizar narrowing dentro de blocos case que contêm expressões booleanas:

type ApiResponse = 
  | { status: "success"; data: unknown }
  | { status: "error"; message: string; code: number }
  | { status: "loading"; progress: number }
  | null
  | undefined;

function handleResponse(response: ApiResponse): string {
  switch (true) {
    case response === null:
      return "Response is null";

    case response === undefined:
      return "Response is undefined";

    case response.status === "success":
      return `Data: ${JSON.stringify(response.data)}`;

    case response.status === "error":
      return `Error ${response.code}: ${response.message}`;

    case response.status === "loading":
      return `Loading... ${response.progress}%`;

    default:
      return "Unknown response state";
  }
}

O TypeScript infere corretamente o tipo em cada case porque a expressão booleana atua como um type guard, estreitando o tipo da variável no escopo do bloco.

3. Vantagens e Desvantagens do Switch(true)

Vantagens:

// Legibilidade superior vs if-else aninhados
type UserAction = 
  | { type: "view"; id: number }
  | { type: "edit"; id: number; changes: Record<string, unknown> }
  | { type: "delete"; id: number; confirm: boolean }
  | { type: "create"; data: Record<string, unknown> };

// Com switch(true)
function executeAction(action: UserAction): void {
  switch (true) {
    case action.type === "view":
      console.log(`Viewing item ${action.id}`);
      break;
    case action.type === "edit":
      console.log(`Editing item ${action.id} with changes`);
      break;
    case action.type === "delete" && action.confirm:
      console.log(`Deleting item ${action.id}`);
      break;
    case action.type === "create":
      console.log("Creating new item");
      break;
    default:
      console.log("Unknown action");
  }
}

Desvantagens:

  • Perda de escopo de narrowing em alguns casos complexos
  • Verbosidade para padrões simples
  • Necessidade de break explícito (ao contrário de early returns)
// Comparação com if-else e early returns
function executeActionWithEarlyReturn(action: UserAction): void {
  if (action.type === "view") {
    return console.log(`Viewing item ${action.id}`);
  }
  if (action.type === "edit") {
    return console.log(`Editing item ${action.id}`);
  }
  // ...
}

4. Pattern Matching Proposals no TypeScript

O repositório do TypeScript possui várias propostas relacionadas a pattern matching. A mais notável é a proposta de when clauses:

// Sintaxe proposta (não implementada)
type Result<T> = 
  | { ok: true; value: T }
  | { ok: false; error: Error };

function handleResult<T>(result: Result<T>) {
  match (result) {
    when { ok: true, value: v } => console.log(v);
    when { ok: false, error: e } => console.error(e);
  }
}

Outras propostas incluem case patterns em switch statements e expressões match com suporte a destructuring. Atualmente, nenhuma dessas propostas foi implementada na linguagem principal.

5. Simulando Pattern Matching com Type Guards e Switch(true)

Podemos criar type guards customizados para simular pattern matching:

// Type guards customizados
function isSuccessResponse<T>(response: ApiResponse): response is { status: "success"; data: T } {
  return response !== null && response !== undefined && response.status === "success";
}

function isErrorResponse(response: ApiResponse): response is { status: "error"; message: string; code: number } {
  return response !== null && response !== undefined && response.status === "error";
}

// Combinando com switch(true)
type PaymentPayload = 
  | { method: "credit"; cardNumber: string; cvv: string }
  | { method: "pix"; key: string }
  | { method: "boleto"; dueDate: Date };

function validatePayment(payload: PaymentPayload): boolean {
  switch (true) {
    case payload.method === "credit":
      return payload.cardNumber.length === 16 && payload.cvv.length === 3;

    case payload.method === "pix":
      return payload.key.includes("@") || payload.key.length === 11;

    case payload.method === "boleto":
      return payload.dueDate > new Date();

    default:
      return false;
  }
}

6. Exaustividade e Type Safety com Switch(true)

Para garantir que todos os casos sejam cobertos, utilizamos o tipo never:

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}

type Direction = "up" | "down" | "left" | "right";

function move(direction: Direction): string {
  switch (true) {
    case direction === "up":
      return "Moving up";
    case direction === "down":
      return "Moving down";
    case direction === "left":
      return "Moving left";
    case direction === "right":
      return "Moving right";
    default:
      return assertNever(direction); // Erro em tempo de compilação se não exaustivo
  }
}

Se adicionarmos um novo membro ao tipo Direction sem atualizar o switch, o TypeScript apontará erro no assertNever.

7. Padrões Emergentes e Boas Práticas

Quando preferir switch(true):

  • Múltiplas condições que não são mutuamente exclusivas via discriminated unions
  • Validação de payloads com formatos variados
  • Narrowing baseado em múltiplas propriedades

Organização de código:

// Extraindo condições em funções auxiliares
function isPositiveNumber(value: unknown): value is number {
  return typeof value === "number" && value > 0;
}

function isValidString(value: unknown): value is string {
  return typeof value === "string" && value.length > 0;
}

function processInput(input: unknown): string {
  switch (true) {
    case isPositiveNumber(input):
      return `Number: ${input * 2}`;
    case isValidString(input):
      return `String: ${input.toUpperCase()}`;
    default:
      return "Invalid input";
  }
}

Cuidados com desempenho: Em loops críticos, switch(true) pode ser ligeiramente mais lento que if-else devido à avaliação de expressões booleanas. Para a maioria dos casos, porém, a diferença é insignificante.

8. Conclusão e Próximos Passos

O padrão switch(true) oferece uma maneira elegante de realizar type narrowing em cenários complexos, especialmente quando combinado com type guards customizados. Embora não substitua completamente o pattern matching nativo que pode vir no futuro, ele preenche uma lacuna importante na expressividade do TypeScript atual.

Para se aprofundar, explore temas vizinhos como o operador satisfies, type guards com predicados de tipo, e const type parameters, que expandem ainda mais as capacidades de type safety da linguagem.

Referências