← Глава 23. Утилитные типы изнутри Содержание Глава 25. Type guards и сужение в рантайме →

Глава 24. Где компилятор лжёт

Глава 24. Где компилятор лжёт

Каст, который не бросает

Вот код, который вы видели в каждом втором проекте и, скорее всего, писали сами:

type User = { id: string; name: string; isAdmin: boolean };

async function loadUser(req: Request): Promise<User> {
  const body = await req.text();
  const user = JSON.parse(body) as User; // «это User»
  return user;
}

Он компилируется. Он работает на стенде, потому что на стенде вы шлёте ровно тот JSON, который ждёте. Он проходит ревью, потому что выглядит аккуратно: разобрали тело, сказали, что это User, вернули User. И он становится ложью в ту секунду, когда тело пришло другим — урезанным клиентом старой версии, искажённым прокси, подделанным руками. user.isAdmin окажется undefined, а undefined в булевом контексте — это false, и хорошо ещё если false, а не наоборот. Никто об этом не узнает, пока где-нибудь дальше по коду isAdmin тихо не пропустит того, кого должен был остановить.

Рефлекс приходит из статически типизированного мира мгновенно: неверный каст бросает исключение. Привёл объект не к тому типу — получи ошибку на месте, громко, в стектрейсе, с указанием строки. Так устроена половина защитных механизмов, к которым вы привыкли. На этом рефлексе и держится ложное спокойствие при виде as User: кажется, что если объект окажется не User, что-то сломается прямо здесь и сейчас.

Не сломается. as стёрт. В скомпилированном файле от него не остаётся ничего:

// после компиляции — то, что реально исполняется
async function loadUser(req) {
  const body = await req.text();
  const user = JSON.parse(body); // ни следа `as User`
  return user;
}

as User не проверяет ни одного поля. Он не сравнивает форму. Он вообще не доживает до рантайма — это инструкция проверяющей программе, а не исполняющей. Неверный тип не падает, не бросает, не сигналит. Он едет дальше под видом User, и каждая следующая строка обращается с ним как с User, потому что проверяющий так и записал. Ложь не отлавливается — она распространяется.

Эта глава открывает часть книги про границу рантайма, и начать её честно — значит начать с признания. До сих пор мы строили доказательства: типы как утверждения, проверяемые отдельной программой. Теперь надо назвать места, где доказательство не держится, — где «компилируется» перестаёт значить «истинно». Их немного, и они не случайны. Вопрос главы: где именно проходит эта граница и почему почти все дыры собрались вдоль неё.

«Компилируется» значит «согласованно при допущениях», не «истинно»

Начнём с того, что зелёная сборка вообще утверждает. Соблазнительно прочитать её как «программа верна». Это не то, что говорит компилятор, и важно услышать, что он говорит на самом деле.

Проверяющий типов работает как машина вывода: берёт ваши утверждения о значениях и проверяет, что из них не следует противоречие. Утверждения — это аннотации, объявления типов, сигнатуры функций. Если всё сходится, противоречий не найдено, сборка зелёная. Но вывод честен ровно настолько, насколько честны посылки. Скормите ему ложную посылку — и он добросовестно построит на ней непротиворечивое здание.

const total: number = "500" as unknown as number; // посылка: «это число»

console.log(total.toFixed(2)); // следствие принято без возражений
// проверка пройдена. рантайм: total.toFixed is not a function

Проверяющий не ошибся. Ему сказали total: number, он поверил и проверил всё остальное относительно этой веры. toFixed на числе допустим — следствие верное. Ложна была посылка, и в посылку он заглянуть не может: она дана ему как факт. Зелёная сборка означает «при ваших допущениях противоречий нет», а не «ваши допущения верны».

Обычно эта оговорка ничего не стоит, потому что обычно допущения вы не выдумываете — они выводятся из кода, который проверяющий видел целиком:

const order = { id: "A-1", total: 500 };
order.total.toFixed(2); // допущение `total: number` выведено, не объявлено

Здесь нет щели между «компилируется» и «верно». Проверяющий сам увидел литерал 500, сам заключил number, и его вывод привязан к реальности кода. Соврать тут негде — он не принимал ничего на веру, он всё вывел. Безопасность зелёной сборки прямо пропорциональна тому, какая доля допущений выведена, а не объявлена вами вручную.

Дыра — это место, где допущение объявлено, а не выведено, и где оно может оказаться ложным. Не там, где проверяющий ошибся в рассуждении — в рассуждении он не ошибается, — а там, где ему дали ложную посылку и закрыли дорогу к проверке. TypeScript устроен так, что таких мест немного и они распознаваемы. Это не разбросанные по языку баги. Это конечный, перечислимый набор конструкций, и каждая из них — осознанный размен: возможность соврать проверяющему в обмен на возможность типизировать живой, чужой, динамический JavaScript, который иначе не типизируется вовсе. Несостоятельность (unsoundness) здесь — не дефект реализации, а проектное решение, и разобрать его стоит как карту: знать каждую дыру в лицо.

Дальше — каталог. Три раздела по природе дыр: те, что вы пробиваете сами; те, что втекают снаружи; и те, что встроены в систему типов как уступки.

Каталог дыр: явные переопределения

Первый класс — места, где вы своей рукой велите проверяющему поверить на слово. Здесь нет ничего скрытого: вы видите конструкцию, вы её написали, ответственность на вас. Опасность не в том, что они существуют, а в том, как легко их ставить не думая.

any — отказ от проверки. any совместим со всем в обе стороны и отключает контроль на всём, чего касается. Значение типа any можно вызвать, проиндексировать, сложить с чем угодно — проверяющий молчит, потому что ему сказали молчать.

function handle(payload: any) {
  payload.user.profile.settings.theme; // ноль возражений
  payload();                            // тоже ноль
  payload.toFixed(2);                   // и тут ноль
}

any не говорит «я не знаю тип». Он говорит «не проверяй». Разница принципиальная: для «не знаю» есть unknown — верхний тип, который заставляет сузить значение прежде, чем что-либо с ним сделать. any ничего не заставляет, и потому он заразен: значение any, присвоенное переменной без аннотации, делает any и её, и расползается по коду, гася проверку на своём пути.

as — обещание, а не проверка. Утверждение типа (type assertion) переписывает то, во что верит проверяющий, и ничего не делает в рантайме. Это вы уже видели. У него есть встроенный предохранитель — нельзя утверждать между совсем уж несвязанными типами:

const n = 42;
const s = n as string;
// ошибка: преобразование типа 'number' в 'string' может быть ошибкой,
// так как типы недостаточно пересекаются

Предохранитель снимается двойным утверждением через unknown. unknown совместим сверху с любым типом, поэтому маршрут as unknown as T отмывает что угодно во что угодно:

const s = n as unknown as string; // проверка пройдена, в s по-прежнему число

as unknown as T — это полный отказ от проверки в одной строке, синтаксически невинной. Он не оставляет в рантайме ничего; в s лежит число 42, а проверяющий уверен, что там строка. Любой код после этой строки построен на ложной посылке.

! — утверждение, что не null. Постфиксный ! (non-null assertion) снимает из типа null и undefined, ничего не проверяя:

function ship(order: Order | undefined) {
  return order!.id; // «он точно есть» — без всякой проверки
  // рантайм, если undefined: Cannot read properties of undefined
}

Тот же приём, что as, в миниатюре: вы сужаете тип силой, под свою ответственность, и стираете гарантию, которую система типов честно держала. Там, где order действительно мог быть undefined, ! превращает пойманную в типах возможность null обратно в необработанную дыру.

@ts-ignore и @ts-expect-error — отключение строки. Комментарий-директива глушит ошибку на следующей строке целиком:

// @ts-ignore
const x: number = someUntypedThing(); // что бы тут ни было не так — молчок

Это самый грубый из переопределений: он не сужает и не утверждает конкретный тип, он просто выключает проверяющего на одну строку. @ts-expect-error чуть честнее — он требует, чтобы ошибка на строке была, и сам становится ошибкой, если она исчезла. Это делает его самозачищающимся: убрали причину — компилятор напомнит убрать и подавление. У @ts-ignore такого свойства нет, и потому он переживает рефакторинги, продолжая глушить уже не ту ошибку, которую глушил изначально.

Общее у всех четырёх — вы их видите. Они написаны в коде, их можно найти поиском, на них можно навести правило линтера. Это самые честные из дыр: явные, локальные, ваши.

Каталог дыр: внешние данные

Второй класс куда коварнее, потому что дыра здесь часто невидима — она прячется не в as, а в типе, который вернула чужая функция. Это всё, что втекает в программу извне: проверяющий этих данных не видел и видеть не мог, а тип на них поставили вы или библиотека, на веру.

Самый ходовой источник — разбор JSON. JSON.parse по сигнатуре возвращает any:

const data = JSON.parse('{"id":"A-1"}'); // тип data: any
data.total.toFixed(2);                   // ноль возражений, в рантайме падает

Ни одного утверждения вы не написали — и всё равно открыли дыру, просто вызвав функцию из стандартной библиотеки. any на выходе JSON.parse означает: проверяющий снимает с себя ответственность за всё, что пришло из строки, и правильно делает — он эту строку не разбирал и не знает её формы. То же с сетью:

const res = await fetch("/api/order");
const order = await res.json(); // тип: any
order.status;                   // any — едет дальше без единой проверки

res.json() тоже отдаёт any. Между сервером и вашим кодом — байты, у байтов нет типов, и fetch честно это признаёт. То же касается всего остального периметра: process.env (каждое поле — string | undefined, и undefined тут не формальность), аргументы process.argv, прочитанные файлы, строки из базы, тела сообщений из очереди, ввод формы. Это getTotal из первой главы во всех его обличьях: значение приходит в рантайм без типа, кто-то ставит на него аннотацию, и с этого момента проверяющий верит аннотации, а не данным — потому что данных он не видит.

const order = (await res.json()) as Order; // тип теперь точный — и полностью на веру
order.total.toFixed(2);                    // компилируется. истинно — если повезло

Это крупнейший практический источник лжи в реальных приложениях. Не экзотические касты в недрах библиотеки, а обыденный fetch().json() плюс аннотация, который пишут каждый день не задумываясь. Объём входных данных в типичном сервисе огромен, и каждый их байт — непроверенное допущение, если на границе не стоит настоящая рантайм-проверка.

Сюда же примыкают два более тонких случая. Первый — файл деклараций (.d.ts), описывающий нетипизированную библиотеку. Если декларация врёт о том, что библиотека реально возвращает, проверяющий унаследует её ложь и распространит по вашему коду, не заподозрив подвоха (подробно — в главе 28). Второй — пользовательский страж (type guard), которому компилятор верит без проверки:

function isOrder(x: unknown): x is Order {
  return typeof x === "object" && x !== null && "id" in x;
  // забыли проверить total — но компилятор это не контролирует
}

Сигнатура x is Order — обещание, а не доказательство. Проверяющий принимает его как факт: внутри ветки, где isOrder(x) истинно, он считает x полноценным Order, хотя проверка total забыта. Тело стража — ещё одно место, где вы говорите «поверь мне», просто оформленное как функция (об устройстве и дисциплине стражей — глава 25).

Каталог дыр: структурные

Третий класс — уступки, встроенные в саму систему типов. Их вы не пробивали и не вносили снаружи; они там по умолчанию, как часть проектного компромисса между строгостью и удобством. Их меньше всего, но именно они удивляют сильнее всего, потому что нарушают ожидание, что уж внутри-то системы всё сходится.

Ковариантность массивов и мутация. TypeScript считает Cat[] совместимым с Animal[], что удобно для чтения и опасно для записи:

type Animal = { name: string };
type Cat = Animal & { meow: () => void };
type Dog = Animal & { bark: () => void };

const cats: Cat[] = [{ name: "Том", meow: () => {} }];
const animals: Animal[] = cats; // разрешено: Cat[] — это Animal[]

animals.push({ name: "Рекс" } as Dog); // компилируется
cats[1].meow();                        // в рантайме: meow is not a function

Через animals мы затолкали Dog-образный объект в массив, который на самом деле cats. Проверяющий пропустил это без звука: на шаге animals.push он видит Animal[] и считает Animal валидным элементом. Массив один и тот же, и cats[1] теперь не Cat. Рантайм-проверки на запись нет — система типов сознательно закрыла на это глаза в обмен на то, чтобы массивы вообще были удобно совместимы по подтипу.

Бивариантность параметров методов. Параметры функций должны были бы быть контравариантны, но для методов, записанных в синтаксисе метода, TypeScript ослабляет правило до бивариантности — снова ради совместимости с массой реального кода (механика разобрана в главе 13). Итог тот же: некоторые присваивания функций проходят проверку, хотя в рантайме приведут к вызову с неподходящим аргументом.

Индексный доступ без noUncheckedIndexedAccess. По умолчанию обращение по индексу или ключу даёт тип элемента, молча отрицая, что там может не быть ничего:

const items = [1, 2, 3];
const x = items[10]; // тип: number. в рантайме: undefined
x.toFixed(2);        // компилируется. рантайм: Cannot read properties of undefined

items[10] имеет тип number, хотя по факту это undefined. Та же дыра — у доступа к записи по строковому ключу. Флаг noUncheckedIndexedAccess закрывает её, добавляя | undefined к результату индексации и заставляя проверить (об этом — в главах 5 и 12), но по умолчанию он выключен, и дыра открыта.

Object.keys и for-in теряют точность ключа. Перебор ключей объекта отдаёт string, а не keyof T:

const order = { id: "A-1", total: 500 };
for (const key of Object.keys(order)) {
  // тип key: string, а не "id" | "total"
  order[key]; // ошибка под noImplicitAny, если включён
}

Это, в отличие от прочих, дыра обоснованная: объект в рантайме может содержать ключи сверх объявленных в типе (структурная типизация это прямо допускает), так что keyof T было бы здесь ложным обещанием. Но на практике она ломает наивный перебор и толкает к утверждениям, чтобы его дописать, — то есть провоцирует открыть дыру предыдущего класса.

Все три первых пункта — места, где система предпочла удобство строгости и приняла известную дыру осознанно. Их не найти поиском по as, на них не повесить правило «запретить»: они в фундаменте. Знать их — единственная защита.

Глазами джависта

Рефлекс «неверный каст бросает» родом из Java, и там он верен. Каст (Foo) x статически несостоятелен ровно как as — компилятор пропускает его, доверяя программисту. Но Java не оставляет это доверие непроверенным: рантайм помнит типы, и в момент каста JVM сверяет реальный класс объекта. Не совпал — ClassCastException, громко, на месте, со стектрейсом. Несостоятельность каста подперта рантайм-сетью.

Та же сеть натянута и под другими дырами Java. Ковариантность массивов — Object[] a = stringArray; a[0] = 42; — компилируется так же, как наш animals.push, но в рантайме ловится: ArrayStoreException на записи. null, проскочивший мимо системы типов, всплывает как NullPointerException. Везде один паттерн: статика пропускает, рантайм ловит. Java несостоятельна — но падает шумно и в точке преступления.

В TypeScript этой сети нет вообще. Под as не стоит проверяемый каст: к рантайму типы стёрты, сверять реальный класс не с чем, потому что классов в этом смысле нет. animals.push не бросит ArrayStoreException — JavaScript не знает, что массив «должен» содержать только котов. order!.id не даст осмысленного NPE в точке утверждения — он даст Cannot read properties of undefined где-то дальше, когда undefined доберётся до обращения. Неверное допущение не падает там, где допущено. Оно едет дальше и разрушает молча, всплывая позже и в стороне.

Вот почему рефлекс «неверный каст бросает» здесь не просто неверен — он опасен. Он внушает спокойствие именно там, где надо насторожиться. as в TypeScript — не проверяемый каст Java, а голое обещание проверяющему, стираемое перед запуском. Граница доверия, о которой шла речь с первой главы, здесь становится конкретной: в Java на краю стоит рантайм-сеть, в TypeScript на краю не стоит никто, кроме вас.

Где дыры особенно подлы

Каталог — это карта. Но карта не передаёт, почему по этой местности так легко оступиться. Несколько мест, где дыры из честных превращаются в коварные.

as — ложный друг. Он читается как преобразование, как (int) в Си или (Foo) в Java, — операция, которая что-то делает с данными. Он не делает ничего. Это чистое переписывание веры проверяющего, и глаз, натренированный на касты-с-проверкой, видит в нём защиту, которой нет. Чем опытнее программист в языках с проверяемым кастом, тем сильнее этот ложный друг.

Двойное утверждение прячется на ревью. value as unknown as Target отмывает что угодно во что угодно, и на беглом чтении выглядит как техническая возня с типами, а не как полный отказ от проверки, которым является. Ревьюер, скользящий глазами по диффу, отметит as Target и не задержится на as unknown перед ним — а именно unknown снял предохранитель.

«Компилируется и работает в деве» — ложная уверенность. Дыра на внешних данных не проявляется, пока форма данных совпадает с ожиданием, а на стенде она совпадает всегда — вы сами шлёте эти данные. Ложь спит до первого входа, которого вы не тестировали: пропущенное поле, null вместо строки, число вместо объекта. Зелёный CI и работающий дев тут не свидетельства корректности, а свидетельства того, что вы ещё не встретили расходящийся вход.

Один заражённый пакет травит внутрь. Зависимость, чьи типы построены на any или чей .d.ts врёт, протаскивает any через свою границу в ваш код. Вы вызываете честно выглядящую функцию, получаете any, присваиваете переменной — и дыра уже внутри вашего «хорошо типизированного» нутра, хотя вы не написали ни одного as.

Забытый @ts-expect-error перестаёт быть проблемой сам, как мы видели, — а вот забытый @ts-ignore остаётся миной: код под ним меняется, ошибка под ним меняется, а глушитель продолжает работать, скрывая уже настоящую новую проблему.

И главное — дыры складываются. Каждая по отдельности — известный размен. Сцепленные, они дают уверенную ложь насквозь:

const res = await fetch("/api/order");
const order = (await res.json()) as Order; // any из json + точное утверждение
order.total.toFixed(2);                    // компилируется, типы «идеальны»

res.json() дал any (дыра внешних данных). as Order превратил any в точный тип без единой проверки (явное переопределение). Результат — переменная order, которую и проверяющий, и читатель кода считают железно типизированным Order, а в рантайме она ровно то, что прислал сервер, и ни байтом надёжнее. Типы выглядят безупречно. Уверенность полная. Основание — нулевое. Это не редкий патологический случай; это самый обычный способ читать ответ API, и именно его надо научиться видеть как то, чем он является.

Дыры кучкуются на границе — защищай края, доверяй нутру

Теперь переключатель. Разложив дыры по трём классам, посмотрите, где они оказались, — и проступит карта, которая задаёт всю дисциплину дальнейших глав.

Они не размазаны по языку ровным слоем. Они собраны на границе. Внешние данные — это буквальная граница системы, стык с нетипизированным миром. Явные переопределения — тоже граница, но проведённая вашей рукой: каждый as, !, any, @ts-ignore — это локальная точка, где вы вручную разорвали связь вывода с реальностью. Структурные уступки малочисленны, известны поимённо и в основном тоже срабатывают на стыках — индексация на краю массива, мутация чужого массива, перебор ключей пришедшего объекта. Внутри хорошо типизированного нутра, где всё выведено из кода, который проверяющий видел целиком, врать негде — там нет посылок, принятых на веру.

Из этого вытекает дисциплина, и она простая: скепсис лей на края, доверяй нутру. Не размазывай подозрительность по всему коду — это и непродуктивно, и невозможно. Сосредоточь её там, где живут допущения. Граница входных данных, каждое явное переопределение, каждый стык с чужим кодом — вот периметр обороны. За периметром, в коде, где типы выведены, проверяющий честен, и ему можно верить настолько, насколько ты не пробивал в нём дыр сам.

Практически это значит обращаться с каждым as, !, any и каждым байтом внешних данных как с тем, чем они являются, — непроверенным допущением. Допущение неизбежно; задача не в том, чтобы их извести, а в том, чтобы каждое было осознанным, локализованным и, где возможно, заменённым настоящей рантайм-проверкой. Не as Order на ответе fetch, а проверка формы в исполняющей программе, после которой тип уже не обещание, а вывод из состоявшейся проверки. Чем заменять — содержание оставшейся части книги: пользовательские стражи (глава 25), схемы как источник истины (глава 26), типизированные ошибки на границах (глава 27), честные декларации чужого кода (глава 28).

И отдельно — разучи рефлекс «неверный каст бросает». В языке с проверяемым кастом он спасает; здесь он усыпляет ровно там, где нужна бдительность. В TypeScript ложь не бросает. Она молчит, едет дальше и всплывает позже, в стороне от места, где её впустили. Сети под ней нет — кроме той, что натянешь ты сам, в нужной точке, своей рукой.

Итог

Проверяющий честен внутри своих допущений и лжёт ровно там, где допущения объявлены, а не выведены, и потому могут быть ложными, — а это границы: внешние данные, явные переопределения, немногие структурные уступки. Кто знает эту карту наизусть и держит оборону на краях, а не размазывает её по всему коду, тот не путает «компилируется» с «верно».

← Глава 23. Утилитные типы изнутри Содержание Глава 25. Type guards и сужение в рантайме →