← Глава 24. Где компилятор лжёт Содержание Глава 26. Схемы как источник истины →

Глава 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: он действительно смотрит на значение. Лживый страж хуже 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 невыразимо (нет типов-сумм), а в TS typeof режет объединение на варианты. То, что в 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, боится дрейфа и доверяет своим стражам ровно потому, что знает их цену.

← Глава 24. Где компилятор лжёт Содержание Глава 26. Схемы как источник истины →