Narrowing: refinamento de tipos com type guards

1. O que é Narrowing e por que precisamos dele?

Narrowing é o processo pelo qual o TypeScript reduz um tipo de união para um tipo mais específico dentro de um bloco de código. Quando declaramos uma variável como string | null, o TypeScript não nos permite acessar métodos específicos de string sem antes verificar se o valor não é nulo.

function processName(name: string | null) {
  // Erro: Object is possibly 'null'
  // return name.toUpperCase();

  // Com narrowing, o TypeScript entende que name é string
  if (name !== null) {
    return name.toUpperCase(); // ✅ name é string aqui
  }
  return "default";
}

Diferente do type casting com as, que força um tipo sem verificação em tempo de execução, o narrowing oferece segurança:

const value: unknown = "Hello";

// Type casting (inseguro)
const length1 = (value as string).length; // Pode quebrar em runtime

// Narrowing (seguro)
if (typeof value === "string") {
  const length2 = value.length; // ✅ TypeScript sabe que é string
}

2. Type Guards nativos do JavaScript

O typeof é o type guard mais básico e funciona para tipos primitivos:

function formatValue(value: string | number | boolean) {
  if (typeof value === "string") {
    return value.toUpperCase(); // ✅ value é string
  }
  if (typeof value === "number") {
    return value.toFixed(2); // ✅ value é number
  }
  return value ? "true" : "false"; // ✅ value é boolean
}

O instanceof verifica se um objeto é instância de uma classe:

class ApiError {
  constructor(public statusCode: number) {}
}

class ValidationError {
  constructor(public field: string) {}
}

function handleError(error: ApiError | ValidationError) {
  if (error instanceof ApiError) {
    console.log(`Status: ${error.statusCode}`); // ✅
  } else {
    console.log(`Campo inválido: ${error.field}`); // ✅
  }
}

Limitações importantes: typeof null retorna "object", e typeof não diferencia objetos personalizados.

3. Type Guards baseados em igualdade e truthiness

Operadores de igualdade refinam tipos comparando valores específicos:

function setTheme(theme: "light" | "dark" | "system") {
  if (theme === "light") {
    document.documentElement.setAttribute("data-theme", "light"); // ✅
  } else if (theme === "dark") {
    document.documentElement.setAttribute("data-theme", "dark"); // ✅
  }
  // theme é "system" aqui
}

Truthiness elimina valores falsy como null, undefined, 0, "":

function getLength(value: string | null | undefined) {
  if (value) {
    return value.length; // ✅ value é string (elimina null e undefined)
  }
  return 0;
}

O operador in verifica propriedades em objetos:

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(pet: Fish | Bird) {
  if ("swim" in pet) {
    pet.swim(); // ✅ pet é Fish
  } else {
    pet.fly(); // ✅ pet é Bird
  }
}

4. Type Guards definidos pelo usuário (Type Predicates)

Criamos funções guarda personalizadas usando value is Type:

interface User {
  name: string;
  email: string;
}

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "name" in value &&
    "email" in value
  );
}

function processData(data: unknown) {
  if (isUser(data)) {
    console.log(data.name); // ✅ TypeScript sabe que é User
    console.log(data.email); // ✅
  }
}

Boas práticas: a função deve retornar booleano e a lógica deve ser consistente com a assinatura de tipo.

5. Narrowing com discriminated unions (uniões discriminadas)

Uniões discriminadas usam uma propriedade literal comum para identificar cada tipo:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "triangle"; base: number; height: number };

function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2; // ✅ shape é circle
    case "square":
      return shape.side ** 2; // ✅ shape é square
    case "triangle":
      return (shape.base * shape.height) / 2; // ✅ shape é triangle
    default:
      // Exaustividade com never
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

O tipo never no default garante que todos os casos foram tratados. Se adicionarmos um novo tipo sem tratá-lo, o TypeScript apontará erro.

6. Narrowing com never e assertion functions

O tipo never representa valores que nunca ocorrem:

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

type Status = "pending" | "approved" | "rejected";

function processStatus(status: Status) {
  switch (status) {
    case "pending":
      return "Aguardando...";
    case "approved":
      return "Aprovado!";
    default:
      return assertNever(status); // Erro se adicionar novo status sem tratá-lo
  }
}

Assertion functions refinam tipos lançando erros:

function assertIsDefined<T>(value: T): asserts value is NonNullable<T> {
  if (value === null || value === undefined) {
    throw new Error("Valor não definido");
  }
}

function processUser(user: User | null) {
  assertIsDefined(user);
  console.log(user.name); // ✅ user é User (não é null)
}

7. Narrowing avançado: type guards com genéricos e funções de alta ordem

Type guards genéricos criam funções reutilizáveis:

function hasProperty<T extends object, K extends keyof T>(
  obj: T,
  prop: K
): obj is T & Record<K, unknown> {
  return prop in obj;
}

interface Car {
  brand: string;
  year: number;
}

const vehicle: unknown = { brand: "Toyota", year: 2023 };

if (hasProperty(vehicle, "brand")) {
  console.log(vehicle.brand); // ✅ vehicle tem brand
}

Narrowing em arrays com Array.filter e type predicates:

function isString(value: unknown): value is string {
  return typeof value === "string";
}

const mixed: (string | number)[] = ["hello", 42, "world", 100];
const strings: string[] = mixed.filter(isString); // ✅ ["hello", "world"]

Cuidados com narrowing em callbacks:

const items: (string | null)[] = ["a", null, "b"];

// ❌ Erro: filter não refina automaticamente
// const filtered: string[] = items.filter(item => item !== null);

// ✅ Correto: usar type predicate
const filtered: string[] = items.filter((item): item is string => item !== null);

Referências