TS Narrowing и Type Guards

Narrowing — процесс сужения широкого типа (union, unknown) до конкретного внутри ветки кода; type guard — выражение или функция, которая подтверждает компилятору принадлежность значения к определённому типу.

Зачем нужно

  • TypeScript отслеживает поток выполнения и автоматически сужает типы в условных ветках — это называется Control Flow Analysis
  • Без narrowing работа с string | number или unknown требовала бы небезопасных приведений через as
  • Явные type guards позволяют вынести проверку в переиспользуемую функцию с гарантией компилятора

Где используется

  • Обработка ответов API с типом unknown или широкими union
  • Компоненты React с пропами-дискриминантами (type: "text" | "image")
  • Обработчики ошибок (catch (e) — тип unknown в strict-режиме)
  • Парсинг/валидация внешних данных (JSON, FormData)
  • Функции, принимающие несколько типов (перегрузки, полиморфизм)

Основной контент

typeof guard

function format(value: string | number): string {
  if (typeof value === "string") {
    return value.toUpperCase(); // здесь value: string
  }
  return value.toFixed(2); // здесь value: number
}

instanceof guard

class ApiError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
  }
}

function handleError(err: unknown): string {
  if (err instanceof ApiError) {
    return `API ${err.statusCode}: ${err.message}`; // err: ApiError
  }
  if (err instanceof Error) {
    return err.message; // err: Error
  }
  return "Неизвестная ошибка";
}

in operator guard

interface Cat { meow: void; }
interface Dog { bark: void; }

function speak(animal: Cat | Dog): void {
  if ("meow" in animal) {
    animal.meow; // animal: Cat
  } else {
    animal.bark; // animal: Dog
  }
}

Discriminated union (тегированный union)

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2; // shape: { kind: "circle"; radius: number }
    case "rect":
      return shape.width * shape.height;   // shape: { kind: "rect"; ... }
  }
}

User-defined type guard (предикат)

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

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as User).id === "number" &&
    typeof (value as User).name === "string" &&
    typeof (value as User).email === "string"
  );
}

async function fetchUser(id: number): Promise<User> {
  const data: unknown = await fetch(`/api/users/${id}`).then(r => r.json());
  if (isUser(data)) {
    return data; // data: User — компилятор доверяет предикату
  }
  throw new Error("Невалидный ответ API");
}

Assertion function

function assertIsString(val: unknown): asserts val is string {
  if (typeof val !== "string") {
    throw new TypeError(`Expected string, got ${typeof val}`);
  }
}

let value: unknown = "hello";
assertIsString(value);
console.log(value.toUpperCase()); // value: string после assert

Exhaustive check с never

type Status = "active" | "inactive" | "banned";

function describe(status: Status): string {
  switch (status) {
    case "active": return "Активен";
    case "inactive": return "Неактивен";
    case "banned": return "Заблокирован";
    default:
      const _check: never = status;
      throw new Error(`Необработанный статус: ${_check}`);
  }
}

Частые ошибки

  • Использовать as вместо type guardvalue as User обходит проверку, ошибки уходят в рантайм
  • Предикат, который всегда возвращает true — компилятор доверяет предикату без проверки его корректности
  • Не обрабатывать null при typeof obj === "object"typeof null === "object" в JavaScript
  • Discriminated union без общего тега — TypeScript не сможет сузить тип в switch
  • Забыть ветку default с never — при добавлении нового варианта в union ошибка не возникает до рантайма

Связанные темы

Ресурсы