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