Type guards: сужение типа в рантайме

Вопрос с собеседования: почему эта функция не компилируется, хотя логически всё выглядит правильно?

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

function makeSound(animal: Dog | Cat) {
  if (animal.bark) { // ОШИБКА: Property 'bark' does not exist on type 'Cat'
    animal.bark();
  } else {
    animal.meow();
  }
}

Вывод (компилятор):

Property 'bark' does not exist on type 'Cat'.ts(2339)

Ошибка ставит в тупик новичков: ведь если animal — это Dog, у него точно есть bark. Проблема в другом: пока TypeScript не сузил тип animal до Dog, он проверяет выражение animal.bark для всего union-а Dog | Cat целиком — а у Cat такого поля нет. Обращение к полю само по себе компилятора не убеждает. Нужен type guard — конструкция, которую TypeScript распознаёт как доказательство типа.

Type guard (сужение типа, narrowing) — это проверка в коде, глядя на которую компилятор сам делает вывод: «в этой ветке значение точно вот такого типа». Самый базовый — typeof, работает для примитивов (string, number, boolean, undefined, symbol, bigint, function):

function formatValue(value: string | number) {
  if (typeof value === "string") {
    return value.toUpperCase(); // тут value — точно string
  }
  return value.toFixed(2); // а тут — точно number
}

console.log(formatValue("привет"));
console.log(formatValue(3.14159));

Вывод:

ПРИВЕТ
3.14

Для классов и объектов, созданных через конструктор, работает instanceof — он проверяет реальную цепочку прототипов в рантайме:

class ValidationError extends Error {
  field: string;
  constructor(field: string, message: string) {
    super(message);
    this.field = field;
  }
}

function handleError(error: Error) {
  if (error instanceof ValidationError) {
    console.log(`Ошибка в поле ${error.field}: ${error.message}`);
  } else {
    console.log(`Общая ошибка: ${error.message}`);
  }
}

А вот в задаче из вопроса выше ни typeof, ни instanceof не подходят — Dog и Cat это обычные object-типы (интерфейсы/type alias), у которых нет класса и нет разного результата typeof (оба дадут "object"). Здесь работает оператор in — он проверяет, существует ли конкретное имя свойства в объекте, и TypeScript умеет читать эту проверку как type guard:

function makeSoundFixed(animal: Dog | Cat) {
  if ("bark" in animal) {
    animal.bark(); // ОК: тип сужен до Dog
  } else {
    animal.meow(); // ОК: тип сужен до Cat
  }
}

Когда встроенных проверок не хватает — например, нужно провалидировать объект, пришедший из JSON, по сложным правилам, — пишут собственный type guard в виде функции с predicate-типом в сигнатуре: parameterName is Type вместо обычного boolean.

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

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

function greet(value: unknown) {
  if (isUser(value)) {
    console.log(`Привет, ${value.name}!`); // value уже сужен до User
  } else {
    console.log("Это не пользователь");
  }
}

greet({ id: 1, name: "Аня" });
greet("строка");

Вывод:

Привет, Аня!
Это не пользователь

Ещё одна практическая ситуация, где predicate-функции спасают: фильтрация массива. Обычный array.filter(x => x !== null) логически убирает null из массива, но TypeScript этого «не видит» — тип результата всё равно останется прежним union-ом с null внутри, потому что filter в стандартной типизации не умеет связывать булевый результат колбэка с сужением типа элемента. Чтобы результат .filter() реально стал более узким типом, колбэку нужно дать сигнатуру predicate:

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

// без predicate: результат всё ещё (string | null)[]
const withoutGuard = values.filter((v) => v !== null);

// с predicate: результат — string[], null исчез из типа
function isNotNull<T>(value: T | null): value is T {
  return value !== null;
}
const withGuard: string[] = values.filter(isNotNull);

console.log(withGuard.map((s) => s.toUpperCase()).join(", "));

Вывод:

A, B, C

Без predicate-сигнатуры пришлось бы либо мириться с тем, что компилятор считает элементы результата потенциально null (и заставлять писать лишние проверки дальше по коду), либо использовать as string[], отключая проверку вручную. С isNotNull компилятор реально понимает: раз функция вернула true для каждого элемента, значит ни одного null в результате быть не может — и это ровно то же самое сужение типа, что мы видели с isUser, только применённое не к одному значению, а ко всему массиву через filter.

Как это работает под капотом. Всё сужение типов происходит только на уровне компилятора и статического анализа — во время выполнения кода никаких типов уже нет, они стираются при компиляции в обычный JavaScript. Функция isUser в скомпилированном виде — просто обычная функция, которая возвращает true или false, как любая другая. Магия в том, что TypeScript видит специальную сигнатуру value is User и, встретив вызов isUser(value) внутри if, применяет её результат как доказательство типа именно в этой ветке кода — а не потому что где-то реально хранится информация о типах в рантайме.

Частые ошибки на собеседовании: путают type guard с приведением типа через asvalue as User ничего не проверяет в рантайме, это просто просьба к компилятору «поверь мне на слово», и если объект на самом деле не User, ошибка вылезет только позже, при обращении к несуществующему полю. Ещё одна ошибка — писать predicate-функцию, которая формально возвращает boolean, но реально не проверяет то, что обещает в сигнатуре: например, value is User, а внутри функции проверяется только typeof value === "object" без проверки полей — TypeScript поверит такой сигнатуре на слово, и это создаёт ложное чувство типобезопасности. И третья — забывают, что typeof null === "object", поэтому в проверках вроде typeof value === "object" для объектных type guard-ов почти всегда нужна отдельная проверка value !== null.

Проверьте себя
1. Какой оператор используют как type guard, чтобы отличить два object-типа без общего класса и без разного результата typeof, например Dog и Cat из двух разных наборов методов?
Atypeof
Binstanceof
Cin
Das
2. Что делает конструкция value is User в сигнатуре функции-предиката?
AГарантирует, что объект будет преобразован к типу User в рантайме
BУказывает компилятору, что если функция вернёт true, значение внутри этой ветки следует считать типом User
CЗаставляет TypeScript выбросить исключение, если тип не совпадает
DНичего не делает — это просто синоним boolean