TypeGuard в TypeScript
TypeScript добавляет статическую типизацию, но не меняет поведение рантайма. Это создаёт разрыв: типы существуют только при компиляции, а значения — только при выполнении. TypeGuard — механизм, который сокращает этот разрыв, позволяя компилятору сужать типы на основе runtime-проверок. В этой статье разберём control flow analysis, type predicates, assertion functions и научимся валидировать данные из внешних источников.
Содержание
- Фундаментальная проблема
- Control Flow Analysis
- Ограничения встроенных сужений
- Type Predicates — пользовательские type guard
- Assertion Functions
- Практическое применение
- Когда type guard недостаточен
- Ключевые принципы
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Фундаментальная проблема
TypeScript — надмножество JavaScript. Он добавляет статическую типизацию, но не меняет поведение рантайма. Это создаёт разрыв: типы существуют только при компиляции, а значения — только при выполнении.
TypeGuard — механизм, который сокращает этот разрыв. Он позволяет компилятору во время анализа кода понять, какой тип имеет переменная в конкретной точке программы, основываясь на проверках, которые выполняются в рантайме.
Без type guard TypeScript работал бы вслепую: вы знаете, что значение — это string | number, но компилятор не может догадаться, что после typeof value === "string" остался только string.
Control Flow Analysis
Прежде чем говорить о type guard, нужно понять механизм, на котором всё держится — анализ потока управления (control flow analysis).
TypeScript проходит по коду сверху вниз и в каждой точке программы отслеживает, какой тип имеет каждая переменная. Когда он встречает ветвление (if, switch, тернарный оператор), он разделяет поток на ветки и в каждой ветке сужает тип на основе условий.
function process(value: string | number) {
// Здесь value: string | number
if (typeof value === "string") {
// Здесь value: string — TypeScript сузил тип
value.toUpperCase();
} else {
// Здесь value: number — осталась вторая ветка union
value.toFixed(2);
}
}
Ключевой момент: сужение работает только внутри ветки. После if/else тип снова становится string | number, потому что компилятор не знает, какая ветка выполнится.
function example(value: string | number) {
if (typeof value === "string") {
value.toUpperCase(); // ✅ string
}
value.toUpperCase(); // ❌ string | number — тип восстановился
}
TypeScript использует несколько встроенных механизмов для сужения типов. Все они работают через control flow analysis.
typeof — сужение по примитивным типам
typeof возвращает строку, описывающую тип значения. TypeScript знает допустимые результаты для каждого примитивного типа и использует это для сужения.
function format(value: string | number | boolean) {
if (typeof value === "string") {
return value.trim();
}
// Здесь value: number | boolean — string исключён
if (typeof value === "number") {
return value.toFixed(2);
}
// Здесь value: boolean — остались только неохваченные варианты
return value ? "yes" : "no";
}
Ограничение: typeof работает только с примитивами и function. Для объектов typeof возвращает "object", что не даёт никакой полезной информации о структуре.
instanceof — сужение по классу
instanceof проверяет, присутствует ли конструктор.prototype в цепочке прототипов объекта. TypeScript сузит тип до класса (или его подтипов), если проверка прошла.
function handleError(err: Error | string) {
if (err instanceof Error) {
console.error(err.message); // ✅ err: Error
} else {
console.error(err); // ✅ err: string
}
}
in — сужение по наличию свойства
Оператор in проверяет наличие свойства в объекте. TypeScript использует это для сужения discriminated unions и обычных объединений объектов.
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function makeSound(animal: Cat | Dog) {
if ("meow" in animal) {
animal.meow(); // ✅ animal: Cat
} else {
animal.bark(); // ✅ animal: Dog
}
}
Discriminated Unions
Если варианты union имеют общее свойство с литеральным типом (discriminant), TypeScript может сужать по нему через switch или if:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; 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 "rectangle":
return shape.width * shape.height; // ✅ shape: { kind: "rectangle"; width: number; height: number }
}
}
Это работает без type guard, потому что TypeScript встроенно понимает discriminated unions. Но что делать, когда проверки сложнее?
Ограничения встроенных сужений
Встроенные механизмы работают, пока проверки простые. Но как только логика усложняется, TypeScript теряет контекст:
const data: unknown = { name: "Ann", age: 25 };
if (typeof data === "object" && data !== null && "name" in data) {
data.name; // ❌ Error: Property 'name' does not exist on type 'object'
}
Проверка корректна с точки зрения JavaScript, но TypeScript не может вывести из неё конкретную структуру. Для unknown он знает только, что это объект — но не какой именно.
Ещё хуже — когда проверка вынесена в функцию:
function isUser(obj: unknown): boolean {
return (
typeof obj === "object" &&
obj !== null &&
"name" in obj &&
typeof (obj as any).name === "string"
);
}
const data: unknown = { name: "Ann", age: 25 };
if (isUser(data)) {
data.name; // ❌ TypeScript не знает, что isUser гарантирует тип User
}
Функция возвращает boolean. Для TypeScript это просто true или false — никакой информации о типе. Компилятор не заглядывает внутрь функции, чтобы понять, что именно она проверяет.
Type Predicates — пользовательские type guard
Решение — предикат типа (type predicate). Это специальная сигнатура функции, которая явно сообщает компилятору: «Если функция вернула true, значит аргумент имеет указанный тип».
interface User {
name: string;
age: number;
}
function isUser(obj: unknown): obj is User {
return (
typeof obj === "object" &&
obj !== null &&
"name" in obj &&
typeof (obj as any).name === "string" &&
"age" in obj &&
typeof (obj as any).age === "number"
);
}
Синтаксис obj is User — это и есть предикат типа. Он состоит из:
- Имени параметра (
obj) — какой аргумент сужается - Ключевого слова
is— маркер предиката - Целевого типа (
User) — до какого типа сужается
Теперь TypeScript понимает:
const data: unknown = { name: "Ann", age: 25 };
if (isUser(data)) {
data.name; // ✅ data: User
data.age; // ✅ data: User
}
Как это работает внутри
Предикат типа — это контракт, а не проверка. TypeScript доверяет вам: если вы написали obj is User, он верит, что при true объект действительно User. Компилятор не анализирует тело функции.
Это означает две вещи:
1. Вы несёте ответственность за корректность. Если предикат врёт — будет баг, который TypeScript не поймает:
function isUser(obj: unknown): obj is User {
return typeof obj === "object" && obj !== null;
// ❌ Предикат врёт: любой объект пройдёт как User
}
const data: unknown = { foo: "bar" };
if (isUser(data)) {
data.name; // Компиляция пройдёт, но в рантайме — undefined
}
2. Предикат — это только обещание, а не runtime-преобразование. Он не меняет значение, не добавляет свойства. Он говорит компилятору: «трактуй это значение как User в этой ветке кода».
Предикат и else-ветка
Предикат работает в обе стороны. Если функция вернула true — тип сужается до указанного. Если false — тип сужается до исключения указанного типа из union.
type Result = User | Guest;
function isUser(result: Result): result is User {
return "name" in result && "age" in result;
}
function greet(result: Result) {
if (isUser(result)) {
result.name; // ✅ result: User
} else {
result.id; // ✅ result: Guest — User исключён из union
}
}
Это работает, только если исходный тип — union. Для unknown else-ветка не сузится, потому что из unknown нечего исключать.
Assertion Functions
Иногда нужна другая семантика: не «проверить и вернуть boolean», а «проверить и бросить ошибку, если не проходит». Для этого существуют функции-утверждения (assertion functions).
function assertIsUser(obj: unknown): asserts obj is User {
if (
typeof obj !== "object" ||
obj === null ||
typeof (obj as any).name !== "string" ||
typeof (obj as any).age !== "number"
) {
throw new Error("Value is not a User");
}
}
Синтаксис asserts obj is User означает: «Если функция вернулась нормально (не бросила исключение), значит obj имеет тип User».
const data: unknown = fetchUser();
assertIsUser(data);
// Если мы дошли до этой строки — data: User
console.log(data.name); // ✅
Разница между type guard и assertion function:
Type Guard (value is T) | Assertion (asserts value is T) | |
|---|---|---|
| Возвращает | boolean | void |
| При неудаче | Возвращает false | Бросает исключение |
| Использование | В условии if | Как утверждение в потоке кода |
| Сужение | Только внутри ветки if | До конца текущей области видимости |
Assertion functions полезны для валидации на границах системы — при получении данных из API, парсинге конфигов, чтении переменных окружения.
Практическое применение
Валидация API-ответов
Самый частый сценарий — данные извне. TypeScript не может гарантировать, что API вернёт то, что обещала документация. Type guard — мост между «мы надеемся» и «мы проверили».
interface ApiResponse {
status: "success" | "error";
data?: { id: number; title: string };
error?: string;
}
function isSuccessResponse(
res: ApiResponse
): res is ApiResponse & { data: { id: number; title: string } } {
return res.status === "success" && res.data !== undefined;
}
const result = await fetch("/api/data").then(r => r.json());
if (isSuccessResponse(result)) {
console.log(result.data.id); // ✅ TypeScript знает, что data существует
} else {
console.log(result.error); // ✅ TypeScript знает, что это error-ответ
}
Type guard как callback для массивов
Type guard — это обычная функция (value: T) => boolean. Это значит, её можно передавать в .filter(), .find(), .some() — и TypeScript корректно сузит тип элементов:
const values: unknown[] = getMixedData();
const users = values.filter(isUser);
// ✅ users: User[]
const firstUser = values.find(isUser);
// ✅ firstUser: User | undefined
Без предиката .filter() вернул бы unknown[], и пришлось бы делать as User[] — небезопасное приведение.
Исчерпывающая проверка (exhaustiveness check)
TypeScript позволяет гарантировать, что обработаны все варианты union. Если кто-то добавит новый вариант — компилятор выдаст ошибку:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default:
const _exhaustive: never = shape;
return _exhaustive;
}
}
Переменная _exhaustive имеет тип never — тип, которому не может принадлежать никакое значение. Если добавить новый вариант в Shape и не обработать его в switch, TypeScript выдаст ошибку на строке const _exhaustive: never = shape, потому что shape уже не будет never.
Когда type guard недостаточен
Ручные type guard хрупкие. Они не масштабируются на сложные схемы и легко расходятся с реальными данными. Для серьёзной валидации используют runtime-библиотеки, которые одновременно проверяют данные и генерируют типы:
import { z } from "zod";
const UserSchema = z.object({
name: z.string(),
age: z.number(),
});
type User = z.infer<typeof UserSchema>; // { name: string; age: number }
const data: unknown = await fetchUser();
const user = UserSchema.parse(data); // ✅ Проверка в рантайме + тип User
Zod, Valibot, io-ts — все они решают одну проблему: гарантировать, что runtime-значение соответствует compile-time типу. Type guard — это ручная версия того же механизма.
Ключевые принципы
-
Type guard — это мост между рантаймом и системой типов. Он позволяет информации из runtime-проверок влиять на compile-time анализ.
-
Предикат типа (
value is T) — контракт, а не проверка. TypeScript доверяет вам. Если предикат врёт — баг останется незамеченным. -
Control flow analysis — основа всего. Сужение работает только внутри ветки, в которой произошла проверка. После слияния веток тип восстанавливается.
-
Assertion functions (
asserts value is T) — альтернатива для сценариев, где неудача — это исключение, а не нормальная ветка выполнения. -
Для сложных схем используйте библиотеки валидации. Ручные guard подходят для простых случаев. Для API-контрактов и сложных структур — Zod или аналоги.
Ключевые тезисы для интервью
- Type guard — мост между рантаймом и системой типов: позволяет runtime-проверкам влиять на compile-time анализ.
- Control flow analysis — основа сужения типов: TypeScript отслеживает типы в каждой точке программы и сужает их при ветвлении.
typeofработает только с примитивами иfunction; для объектов возвращает"object"без информации о структуре.instanceofпроверяет цепочку прототипов;inпроверяет наличие свойства — оба сужают тип.- Discriminated unions с общим полем-литералом сужаются через
switchилиifбез дополнительных type guard. - Предикат типа (
value is T) — контракт, а не проверка: TypeScript доверяет вам, корректность на вашей совести. - Assertion functions (
asserts value is T) — альтернатива для сценариев, где неудача бросает исключение. - Type guard работает как callback для
.filter(),.find()— TypeScript корректно сузит тип элементов. - Exhaustiveness check с
neverвdefault-ветке гарантирует обработку всех вариантов union. - Для сложной валидации используйте Zod, Valibot — они одновременно проверяют данные и генерируют типы.
Заключение
Type guard — необходимый инструмент для работы с данными извне: API, парсинг, пользовательский ввод. Встроенные механизмы (typeof, instanceof, in, discriminated unions) покрывают простые случаи. Для сложной логики используйте предикаты типа (value is T) и assertion functions (asserts value is T). Помните: предикат — это контракт, а не проверка. Для серьёзной валидации предпочитайте Zod или аналоги — они объединяют runtime-проверку с генерацией типов.