Глава 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 ложь не бросает. Она молчит, едет дальше и всплывает позже, в стороне от места, где её впустили. Сети под ней нет — кроме той, что натянешь ты сам, в нужной точке, своей рукой.
Итог
Проверяющий честен внутри своих допущений и лжёт ровно там, где допущения объявлены, а не выведены, и потому могут быть ложными, — а это границы: внешние данные, явные переопределения, немногие структурные уступки. Кто знает эту карту наизусть и держит оборону на краях, а не размазывает её по всему коду, тот не путает «компилируется» с «верно».