Глава 25. Type guards и сужение в рантайме
Глава 25. Type guards и сужение в рантайме
Тип, который надо заслужить
С границы пришёл unknown. Из сети, из JSON.parse, из чужой библиотеки — неважно; глава 24 показала, что это единственный честный тип для всего, что приходит снаружи. А дальше с ним надо работать как с заказом, и значит, в какой-то момент unknown должен стать Order.
type OrderId = string;
type Order = {
id: OrderId;
total: number;
status: "created" | "paid" | "shipped" | "delivered" | "cancelled";
items: { sku: string; qty: number }[];
};
function handle(raw: unknown) {
// нужно, чтобы raw был Order — но как?
console.log(raw.total); // ошибка: 'raw' имеет тип 'unknown'
}
Компилятор прав: на unknown нельзя обратиться ни к одному полю, пока вы не докажете, что поле там есть. Это и есть смысл unknown — верхний тип, в который складывают всё, и из которого нельзя достать ничего, не сузив. Вопрос в том, чем сузить.
Привычный из статически типизированного мира инструмент — проверка по типу прямо в коде — здесь не работает:
if (raw instanceof Order) {} // ошибка: 'Order' — это только тип, не значение
Order объявлен через type. После стирания (глава 3) от него не остаётся объекта, чью цепочку прототипов мог бы проверить instanceof. Order — структурная форма, описание полей, а не класс; проверять instanceof не у чего. Второй соблазн — заткнуть дыру утверждением:
const order = raw as Order; // «поверь, это Order»
console.log(order.total.toFixed(2)); // рантайм: Cannot read ... of undefined
as ничего не проверяет — он переписывает убеждение проверяющего и стирается перед запуском вместе со всем остальным. Глава 24 занесла его в каталог дыр именно за это: утверждение без проверки. Если в пришедшем объекте нет total, исполняющая программа упадёт, а проверяющая до последнего будет считать, что всё в порядке.
Нужно нечто третье: код, который в рантайме действительно посмотрит на значение, и при этом сообщит проверяющему результат своего осмотра. TypeScript даёт для этого специальную форму функции:
function isOrder(x: unknown): x is Order {
return (
typeof x === "object" && x !== null &&
"id" in x && "total" in x
);
}
function handle(raw: unknown) {
if (isOrder(raw)) {
console.log(raw.total); // ok: внутри ветки raw сужен до Order
}
}
Это type guard, страж типа: функция, чей возвращаемый тип записан не как boolean, а как x is Order — type predicate, предикат типа. Подпись говорит проверяющему: «если я вернула true, считай аргумент сужённым до Order». Внутри if (isOrder(raw)) обращение к raw.total проходит — компилятор поверил подписи и сузил.
И вот первая трещина, ради которой стоит читать дальше. Замените тело на return true — и пример всё так же скомпилируется. Подпись x is Order пообещала проверку, а тело не проверило ничего. Компилятор поверит обещанию, не сверяя его с делом. Значит, вся надёжность стража живёт не в подписи, а в теле, которое пишете вы. Вопрос главы: как в рантайме заработать тип, который компилятор доказать за вас не может, — и чем эта работа честнее, чем as, который выглядит почти так же.
Встроенные стражи — то, что пережило стирание
Прежде чем писать своих стражей, посмотрим на тех, что уже встроены. Их немного, и все они — настоящие операторы JavaScript, которые компилятор научился читать как сужение. Они переживают стирание, потому что им стирать нечего: они спрашивают у значения то, что у значения действительно есть в рантайме.
Самый базовый — typeof. Он возвращает одну из горстки рантайм-категорий, и компилятор по ней сужает:
function format(x: string | number): string {
if (typeof x === "string") {
return x.toUpperCase(); // x: string
}
return x.toFixed(2); // x: number — другая ветка, сужено вычитанием
}
В первой ветке x сужен до string, в else остаётся number — компилятор вычёл проверенный вариант из объединения. Это narrowing, сужение: анализ потока управления, который по ходу кода уточняет тип переменной (глава 8). Каждый встроенный страж — это узнаваемый компилятором повод сузить.
instanceof работает для всего, у чего есть конструктор в рантайме, — классов и встроенных объектов:
function toISO(x: Date | string): string {
if (x instanceof Date) {
return x.toISOString(); // x: Date
}
return x; // x: string
}
in проверяет наличие ключа и сужает объединение по тому, у какого из вариантов этот ключ есть:
type Paid = { status: "paid"; paidAt: number };
type Created = { status: "created" };
function when(o: Paid | Created): number | null {
if ("paidAt" in o) {
return o.paidAt; // o: Paid
}
return null; // o: Created
}
Array.isArray отделяет массив, равенство сужает по литералу, истинность отсекает null и undefined:
function head(items: string[] | undefined): string | null {
if (!items) return null; // items: undefined отсечён
return items[0] ?? null; // items: string[]
}
И самый мощный из встроенных — сужение по дискриминанту размеченного объединения (глава 9). Если у каждого варианта есть общее поле с разным литералом, проверка этого поля сужает до конкретного варианта:
function describe(o: Order): string {
switch (o.status) {
case "shipped": return `Заказ ${o.id} в пути`;
case "delivered": return `Заказ ${o.id} доставлен`;
default: return `Заказ ${o.id}: ${o.status}`;
}
}
Всё это компилятор понимает без единого as и без вашего участия. Но у встроенных стражей есть жёсткий предел: они проверяют только то, что выразимо в рантайме JavaScript. Категорию (typeof), конструктор (instanceof), ключ (in), литерал (===). Для структурной формы — «объект с полями id: string, total: number, items: массив записей { sku, qty }»» — встроенного стража нет. JavaScript не умеет за один оператор спросить «соответствует ли это значение вот этой форме». А именно такой вопрос вы и задаёте границе, когда хотите получить Order`. Здесь встроенные кончаются, и начинается ваш собственный страж.
Пользовательский страж: x is T
Когда форму нельзя проверить одним встроенным оператором, её собирают из нескольких — внутри функции, чья подпись объявляет результат сужения. Соберём стража для Order по-настоящему, поле за полем:
function isItem(x: unknown): x is { sku: string; qty: number } {
return (
typeof x === "object" && x !== null &&
"sku" in x && typeof x.sku === "string" &&
"qty" in x && typeof x.qty === "number"
);
}
function isOrder(x: unknown): x is Order {
return (
typeof x === "object" && x !== null &&
"id" in x && typeof x.id === "string" &&
"total" in x && typeof x.total === "number" &&
"status" in x && typeof x.status === "string" &&
"items" in x && Array.isArray(x.items) &&
x.items.every(isItem)
);
}
Тело — обычный рантайм-код. Никакой магии: typeof, in, Array.isArray, рекурсивный обход массива через every. Каждая проверка — настоящий JavaScript, который исполнится и реально посмотрит на значение. Подпись x is Order — единственная типовая часть; всё остальное доживает до рантайма и работает.
Отметим, как подпись чинит сужение. Внутри тела x начинается как unknown, и после typeof x === "object" && x !== null && "sku" in x компилятор сам сужает x достаточно, чтобы разрешить x.sku. То есть внутри стража действуют те же встроенные правила сужения — вы пишете цепочку проверок, компилятор отслеживает их и пускает к полям. А наружу, в точку вызова, страж отдаёт уже не boolean, а обещание про x:
function handle(raw: unknown) {
if (isOrder(raw)) {
raw.items.forEach((it) => console.log(it.sku)); // raw: Order
} else {
console.log("не заказ"); // raw: unknown
}
}
В then-ветке raw — полноценный Order, со всеми полями и вложенными items. В else он остаётся unknown: страж сказал только то, что в случае true это Order, и ничего не пообещал про случай false. Вы выписали стража один раз — и дальше каждый его вызов превращается в точку сужения, где unknown границы становится известным типом.
Это и есть мост, обещанный главой 1: проверить форму в исполняющей программе и сообщить результат проверяющей. Страж — единственное место в языке, где две программы, разведённые стиранием, обмениваются информацией: рантайм осмотрел значение, статика узнала тип. Но обмен этот держится на доверии, и пора посмотреть, насколько оно обосновано.
Подпись стража — непроверенное обещание
Вернёмся к трещине из первой секции и разглядим её в упор. Компилятор не сверяет тело стража с его подписью. Подпись x is Order он принимает как данность и сужает по ней; что тело при этом действительно проверяет — его не касается.
function isOrder(x: unknown): x is Order {
return true; // не проверено ничего — и это компилируется
}
const order = (JSON.parse("{}") as unknown);
if (isOrder(order)) {
console.log(order.total.toFixed(2)); // рантайм: Cannot read ... of undefined
}
return true — синтаксически законное тело: оно возвращает boolean, а подпись стража совместима с функцией, возвращающей boolean. Компилятор счастлив. В рантайме isOrder всегда говорит «да», order сужается до Order, обращение к order.total падает на пустом объекте. Зелёная сборка, красный рантайм — ровно тот разрыв между двумя программами, о котором шла речь с первой главы.
Назовём это прямо. Страж — это непроверенное обещание, ровно как as. Оба переписывают убеждение проверяющего, не подкрепляя его доказательством, которое компилятор бы проверил. Разница не в природе, а в трёх свойствах:
- Локализованность. Обещание собрано в одной функции, а не размазано по точкам, где иначе стояли бы
as. - Именованность. У обещания есть имя —
isOrder, — и оно читается как утверждение: «здесь мы проверяем, что это заказ». - Исполняемость. В отличие от
as, тело стража доживает до рантайма и может содержать настоящую проверку.asне проверяет никогда; страж — проверяет ровно настолько, насколько вы это написали.
Последнее — и сила, и ловушка. Честно написанный страж лучше as: он действительно смотрит на значение. Лживый страж хуже as. as хотя бы выглядит подозрительно — это явный аварийный люк, его видно при чтении и ищут при ревью. А isOrder(raw) выглядит как добросовестная проверка, и читающий код верит ей не глядя. Утверждение, переодетое проверкой, опаснее голого утверждения, потому что усыпляет.
Отсюда практический вывод, который мы разовьём в острых краях: вся состоятельность стража — в его теле, и тело должно проверять всё, что заявляет подпись. Заявили total: number — проверьте typeof x.total === "number". Подпись — это то, во что вы просите поверить; тело — то, чем вы это заслуживаете. Между ними компилятор не посредник.
Проверь или брось: assertion-функции
У стража есть родственник для случая, когда «не тот тип» — это не ветка логики, а повод остановиться. На границе часто не нужна развилка if/else: данные либо валидны и работа продолжается, либо невалидны и дальше идти нельзя. Для этого есть вторая форма предиката — assertion-функция:
function assertOrder(x: unknown): asserts x is Order {
if (!isOrder(x)) {
throw new Error("Ожидался Order");
}
}
function handle(raw: unknown) {
assertOrder(raw);
console.log(raw.total); // raw: Order — без всякого if
}
asserts x is Order в подписи означает: «если эта функция вернулась нормально (не бросила), считай x сужённым до Order». Сужение действует не внутри ветки, а после вызова — линейно, до конца области видимости. Это знакомый по другим языкам шаблон «assert»: либо условие выполнено и выполнение течёт дальше с уточнённым знанием, либо летит исключение.
Есть и более скромная форма — asserts x без is T, которая просто утверждает истинность (и отсекает null/undefined):
function assert(condition: unknown, msg: string): asserts condition {
if (!condition) throw new Error(msg);
}
function ship(order: Order) {
assert(order.status === "paid", "Отгружать можно только оплаченный");
// здесь order.status сужен до "paid"
}
Assertion-функция — то же непроверенное обещание, что и страж: компилятор не сверяет, что тело действительно бросает в нужных случаях. Пустое тело function assertOrder(x: unknown): asserts x is Order {} скомпилируется и не проверит ничего. Природа та же; меняется только то, как сужение встраивается в поток — веткой или линейно.
Теперь о проблеме, которая подтачивает оба варианта со временем, — дрейф. Страж пишется под конкретную форму типа. Тип живёт и растёт. Добавим в Order поле:
type Order = {
id: OrderId;
total: number;
status: "created" | "paid" | "shipped" | "delivered" | "cancelled";
items: { sku: string; qty: number }[];
currency: string; // новое поле
};
isOrder из предыдущей секции продолжает компилироваться. Подпись по-прежнему x is Order, тело по-прежнему возвращает boolean — компилятору не к чему придраться. Но тело не проверяет currency. Страж теперь лжёт: он пропускает объекты без currency как полноценные Order, и где-то ниже order.currency.toUpperCase() упадёт на undefined. Подпись и тело молча разошлись, и компилятор, который не сверяет их, не поднимет тревогу.
Дрейф — не разовая ошибка, а структурная болезнь рукописных стражей. Каждое изменение типа — это незримое требование пройтись по всем стражам и assertion-функциям и дописать проверки, о котором язык вам не напомнит. Чем больше тип и чем активнее он меняется, тем вернее страж отстаёт от него. Именно отсюда растёт мотивация следующей главы: если и тип, и проверку порождать из одной схемы, дрейф становится невозможен по конструкции — менять приходится одно место, и тип с проверкой меняются вместе (глава 26).
Глазами джависта
В Java сужение по типу — будничная операция, и делает её рантайм.
if (obj instanceof Order) { order.getTotal(); }работает, потому что типы доживают до исполнения (глава 3): JVM помнит класс каждого объекта, иinstanceofспрашивает у объекта его реальный класс. Каст после проверки —(Order) obj— это проверяемый каст: если объект не того класса, JVM сама броситClassCastException. Проверку формы за вас выполняет рантайм, бесплатно и надёжно. Вы её не пишете — она встроена в платформу.В TypeScript этой бесплатной проверки нет, потому что нет того, на чём она держится: типы стёрты, и у значения не спросить его тип. Пользовательский страж — это ровно та работа, которую в Java делает JVM, выполненная вами руками.
typeof,in, обход полей в телеisOrder— это рукописная реконструкция проверяемого каста. Урок главы 3 «типов в рантайме нет, построй проверку сам» здесь принимает конкретную форму: страж — и есть это «сам».И две вещи, которых в Java для сравнения просто не существует. Во-первых, структурное сужение: в Java тип — членский билет класса, и проверить «объект с такими-то полями, неважно какого класса» нечем;
instanceofспрашивает про номинальную принадлежность, а не про форму. В TS форма — и есть тип, поэтому страж проверяет поля, а не класс. Во-вторых, сужение объединения:string | numberв Java невыразимо (нет типов-сумм), а в TStypeofрежет объединение на варианты. То, что в Java один операторinstanceof, в TS распадается на семейство:instanceofдля классов,typeofдля примитивов,inи рукописные стражи для форм, дискриминант для объединений.instanceofесть в обоих языках, но в TS он покрывает лишь узкий угол — классы, — а всё структурное и объединительное приходится строить из других инструментов.Разница, которую стоит унести: в Java проверяемый каст не может солгать — JVM сверяет его с реальностью. В TS страж солгать может, потому что между его подписью и его телом нет того, кто сверяет. Надёжность, которую там даёт платформа, здесь даёте вы — или не даёте.
Где стражи кусаются
Страж — инструмент с открытыми лезвиями. Перечислим, где они режут, без смягчений.
Тело может лгать — грабли номер один. Уже разобрано, но это главное, и потому первое: подпись не сверяется с телом. Лживый страж хуже as, потому что выглядит как проверка и не вызывает подозрений при чтении. Самое опасное место в коде со стражами — не as, который все ищут, а добросовестно выглядящий isOrder, который проверяет половину полей.
Дрейф. Тип растёт — страж молча отстаёт и продолжает компилироваться. Каждое поле, добавленное в тип, — это проверка, которую язык не потребует дописать.
Сторожите unknown, а не any. Если параметр стража объявлен как any, исчезает даже та слабая защита, что компилятор требует сужать перед доступом к полям. На any вы можете обратиться к чему угодно и внутри тела — и написать проверку, которая сама опирается на несуществующие поля, не получив ни замечания. unknown заставляет тело быть честным с самого начала: пока вы не проверили "total" in x, обратиться к x.total внутри стража не дадут. Принимайте unknown — он держит вас в тонусе.
Причуды typeof. Это исторические грабли JavaScript, и страж наследует их целиком:
typeof null; // "object" — не "null"!
typeof []; // "object" — массив неотличим от объекта
typeof (() => {}); // "function"
typeof x === "object" пропустит и null, и массив. Поэтому в каждом структурном страже стоит x !== null, а массив отсекается отдельно через Array.isArray. Забыть x !== null — классическая дыра: null просочится как «объект» и упадёт на первом обращении к полю.
in видит и наследованные ключи. Оператор in проверяет ключ по всей цепочке прототипов, а не только собственные свойства. Объект с унаследованным total пройдёт "total" in x, хотя Object.hasOwn(x, "total") сказал бы «нет». Для данных, пришедших из JSON.parse, это редко проблема — у них голый прототип; но для объектов из чужого кода разница может укусить.
instanceof отказывает на границах реалмов. instanceof сверяет конкретный объект-конструктор. Массив или дата, пришедшие из другого realm — из iframe, из Worker, из vm-контекста Node, — созданы другим конструктором Array/Date, и arr instanceof Array вернёт false, хотя это настоящий массив (глава 3). Поэтому Array.isArray надёжнее instanceof Array: он проверяет внутренний признак массива, а не идентичность конструктора.
Assertion-функции требуют явной аннотации и капризны к стрелочной форме. Тип asserts x is T компилятор не выводит — его обязательно писать руками; без аннотации функция станет обычной, и сужения после вызова не будет. Объявить assertion-функцию как стрелку, присвоенную const без аннотации самой переменной, можно, но толку не будет: вызвать её для сужения нельзя.
const assertOrder = (x: unknown): asserts x is Order => {};
function handle(raw: unknown) {
assertOrder(raw);
// ошибка: Assertions require every name in the call target
// to be declared with an explicit type annotation.
console.log(raw.id);
}
Ошибка вылетает не на самой стрелке, а в точке вызова: компилятор требует, чтобы тип вызываемого имени был виден статически. Лечится либо обычным function, либо явной аннотацией переменной — const assertOrder: (x: unknown) => asserts x is Order = (x) => { ... }. Это ограничение анализа потока, а не каприз.
Страж, проверяющий слишком мало, сужает до ложной точности. Самая тихая беда. Страж не обязан лгать целиком, чтобы навредить, — достаточно проверить меньше, чем заявляет тип:
function isOrder(x: unknown): x is Order {
return typeof x === "object" && x !== null && "id" in x;
// проверен только id — но подпись обещает весь Order
}
Компилятор после такого стража даст обращаться к x.total, x.items, x.status — ко всему, что в типе, — хотя проверен один id. Сужение получилось точнее, чем заслужила проверка. Это не падает сразу и не ловится ревью на беглый взгляд: страж есть, имя правильное, что-то проверяет. Расхождение между «что обещано подписью» и «что проверено телом» — самый частый дефект рукописных стражей, и компилятор в нём не помощник.
Страж — это место, где тип зарабатывают в рантайме
Вот сдвиг, ради которого глава.
В статически типизированном мире, к которому вы привыкли, тип значения — данность: он известен рантайму, его можно спросить, проверка по нему надёжна, потому что её делает платформа. В TypeScript на границе этой данности нет. Значение приходит без типа, и тип на нём — не факт, а то, что предстоит заработать. Страж — место, где его зарабатывают: единственная точка, где исполняющая программа осматривает значение и передаёт результат осмотра проверяющей, наводя мост через стирание.
Из этого следует, как выбирать инструмент. Сначала ищите встроенного стража — место, где JavaScript умеет проверить форму напрямую: категорию через typeof, конструктор через instanceof, ключ через in, литерал-дискриминант через ===. Встроенные не дрейфуют и не лгут — их сверяет сам язык. Пользовательский страж и assertion-функция — для того, что встроенным не выражается: для структурной формы, которую надо проверить поле за полем. Берите их, когда иначе нельзя, и помните цену.
А цена — в том, что подпись стража есть непроверенное обещание. Компилятор поверит ей, не заглянув в тело. Поэтому дисциплина одна и жёсткая: тело должно проверять всё, что заявляет подпись, до последнего поля, — и вы отвечаете за это в одиночку, потому что сверять некому. И берегитесь дрейфа: тип, который растёт, тихо обгоняет стража, который остаётся. Каждое рукописное x is T — это обязательство, которое язык за вас не поддержит.
Последнее, и оно ведёт в следующую главу. Чем сложнее форма, тем менее разумно сторожить её руками: длинный страж легко не доглядеть, а дрейф для большого типа неизбежен. Нетривиальные формы не пишут стражами — их выводят из схемы, где тип и проверка порождаются из одного описания и не могут разойтись (глава 26). Рукописный страж хорош для малого и локального; всё остальное лучше доверить источнику, который чинит дрейф конструктивно. Вы — тот, кто руками делает работу JVM; но там, где работа велика, лучше построить себе станок, чем точить каждый раз вручную.
Итог
Страж возвращает на границе тип, который рантайм стёр, — но ровно настолько, насколько честно его тело, ведь подпись x is T компилятор принимает как обещание, а не проверяет как доказательство. Кто помнит, что страж — это он сам, делающий руками рантайм-проверку, которую в других языках даёт платформа, — тот пишет тело под всю подпись, сторожит unknown, боится дрейфа и доверяет своим стражам ровно потому, что знает их цену.