Где кончается компилятор
Ошибка, из-за которой открывают эту тему, выглядит примерно одинаково: эндпоинт, зелёный в тестах, падает на живом трафике. Причём не на логике — на данных. Поле, которое «должно было быть числом», приехало строкой. Или не приехало вовсе, и вместе с ним не приехал объект, в котором оно должно было лежать.
TypeScript тут не ошибся, потому что не мог. Тип — это утверждение о коде, а не о данных. Компилятор сверяет то, что вы написали, с тем, что вы объявили; значения, приходящие извне, он в принципе не видит. После сборки типы стираются, и в рантайме остаются только данные. Данные ваших интерфейсов не читали.
Отсюда рамка статьи: то, что рождается и умирает внутри вашего процесса, компилятор проверяет честно. То, что пересекает границу процесса, не проверяет никто. Дальше вопрос только в том, где границы и что на них делать руками.
Про то, как ломаются отчёты, когда две стороны по-разному понимают одно поле, мы уже писали в материале о контрактах данных. Это тот же разлом, только со стороны кода.
Карта дыр
Границ у процесса больше, чем кажется, поэтому начнём с инвентаризации. Типичная ошибка здесь не «валидировали неправильно», а «валидировали не там».
HTTP-запросы: тело, query-параметры, заголовки. Тип у req.body обычно any, в лучшем случае unknown. Компилятор о содержимом не знает ничего, и as этого знания не добавляет.
Ответы чужих сервисов. Метод .json() назван доверчиво: он парсит и отдаёт результат как any. Проверять, что внутри, — не его работа. Пока вы не сделали её своей, её не делает никто.
Очереди. Сообщение от соседнего сервиса выглядит надёжным: у производителя же есть типы! Но это вежливость, а не гарантия. Производитель разворачивается по своему расписанию: сегодня он переименует поле, а ваш релиз — в конце недели, и в этом промежутке типы вас не спасут.
Конфиг и окружение. process.env — мешок строк: числа, флаги и списки приезжают строкой или не приезжают.
Кэш, файлы, хранилища, схему которых меняет не ваша команда. Всё, что прошло через JSON.parse, плюс всё, что лежит за пределами процесса. Ограничения БД проверят целостность — null, длину, уникальность, — но не ваши бизнес-правила.
Общий знаменатель один: значение пересекло границу процесса, и для компилятора оно any или unknown, даже если вы уверили его в обратном.
Тупик: ручные проверки и честное слово
Первое, что пробуют, — проверять руками там, где данные пришли: пара typeof в начале хендлера, if (!body?.user?.email) и дальше вглубь по мере надобности. Работает, пока полей пять. Дальше происходит вот что.
Проверка и тип — два артефакта. В интерфейс добавили поле, в проверке забыли. Компилятор молчит: он не знает, что эти два файла связаны. Пустое поле доезжает до продакшена, и обнаруживает его потребитель вашего API — по ошибке там, где её быть не должно.
Проверки расползаются по хендлерам, и каждый решает заново, что считать валидным. Через полгода одно и то же тело на одном эндпоинте принимается, на другом отвергается, и уже никто не помнит, какое из двух поведений правильное.
Родственная попытка — универсальный sanitize-фильтр, который на входе вычищает всё незнакомое. Он защищает структуру, которую вы угадали, а не контракт, который нужен, — и молча выбрасывает данные вместо того, чтобы сказать, что они не приехали.
as — не проверка, а честное слово. req.body as Payment не добавляет в рантайм ни одной операции: это обещание компилятору про значение, которого он никогда не видел. Такое обещание нельзя проверить — можно только нарушить.
Строгий tsconfig тоже ни при чём. Флаги строгости управляют тем, насколько дотошно компилятор читает ваш код. К проводу они отношения не имеют: строка, приехавшая по HTTP, не становится проверенной оттого, что дома всё строго.
Последняя разновидность тупика — «проверим в тестах». Тесты гоняют ваши примеры правильного входа. Реальный вход присылают не вы.
Итог: ручные проверки работают, но недолго и молча устаревают. Для двух полей в админском скрипте это нормальный инструмент. Для контракта между командами — нет.
Что делать руками
Порядок шагов важнее выбора библиотеки, поэтому по шагам.
-
Инвентаризация границ. Выпишите все точки входа данных: HTTP, очереди, env, файлы, чужие API. Границы не равнозначны — начинайте с тех, откуда плохое значение протекает дальше: в записи в базу, в отчёты, в чужие интеграции. Кривое поле на GET-эндпоинте отрисуется как чушь и на этом закончится; кривое поле в отчёте поедет в чужие решения.
-
Схема вместо интерфейса. Единственный источник правды о форме данных — схема, из которой тип выводится. Если сначала написать интерфейс, а потом схему «под него», вы вернулись в тупик: снова два артефакта. Паттерн на примере zod — у него есть эквиваленты вроде valibot или io-ts, и выбор между ними — вопрос вкуса и ограничений проекта, а не принципа:
const Payment = z.object({ amount: z.number().positive(), currency: z.string().length(3), }); type Payment = z.infer<typeof Payment>;Декораторы на классах — та же схема, только другой синтаксис; принцип не меняется. При выборе смотрите на две вещи: качество вывода типов и читаемость сообщений об ошибках. Третий критерий ситуативный — умеет ли схема экспортироваться в JSON Schema: это пригодится, когда контракт нужно показать команде без TypeScript. А если контракт и так живёт как внешний артефакт — OpenAPI-спека, из которой генерируются типы, — вторую схему в коде не заводите. Валидируйте сгенерированной; для JSON Schema есть готовые валидаторы, ajv например.
-
Одна проверка, на границе, сразу. Валидируйте там, где данные вошли, дальше передавайте проверенное. Повторная валидация на каждом слое — те же разъехавшиеся проверки, только в новых одеждах: две схемы одного контракта разъедутся так же, как интерфейс и
typeof. На границе удобенsafeParse: он возвращает результат вместо исключения, и тело ошибки вы формируете сами. Ошибка валидации — это 4xx со структурным телом, а не 500 со стеком; форма этого тела — часть контракта, на неё опираются потребители. -
unknownна входе, бренд на выходе. Непроверенное типизируйте какunknown, не какany:anyотключает проверки,unknownзаставляет их сделать. А чтобы «проверено» было видно в типах, есть приём с брендированием:declare const ok: unique symbol; type Valid<T> = T & { readonly [ok]: true }; function parsePayment(raw: unknown): Valid<Payment> { return Payment.parse(raw) as Valid<Payment>; // единственный санкционированный as }Хендлеры принимают
Valid<Payment>— непроверенное значение в них физически не просунуть, не скомпилируется. Единственный способ получитьValid— пройти через валидатор. В рантайме это ничего не стоит: бренд существует только в типах и стирается вместе с ними. От атак это не спасает — спасает от вашей же невнимательности. -
Конфиг парсится при старте. Переменные окружения читаются один раз, схемой, и процесс падает сразу — с сообщением, какое значение и почему не прошло. Ошибка конфигурации на старте достаётся тому, кто деплоит и может починить. Та же ошибка, пойманная первым рабочим запросом, достаётся пользователю, который ни при чём.
-
Приведение типов — только объявленное. Обрезать пробелы, парсить даты, превращать строку «1» в число — нормально, если это написано в схеме. Молчаливое приведение, рассыпанное по коду, — ложь о данных: вы знаете не то, что реально приехало, а то, чем это стало. Правило простое: всё, что меняет значение, живёт в схеме; код после границы значения не трогает.
Позиция редакции
Мы считаем: единственный источник правды о форме данных — схема; типы выводятся из неё; проверка происходит один раз, на границе; непроверенное внутри кода — unknown. Всё остальное — as на внешних данных, ручные проверки, интерфейсы, написанные рядом со схемой, — технический долг под проценты. Проценты тут — разъехавшиеся артефакты, которые никто не замечает, пока не упадёт отчёт.
Мы неправы в двух случаях.
Первый: короткоживущий код одного автора. Скрипт, где вы и производитель данных, и потребитель, и код умрёт раньше, чем артефакты успеют разъехаться. Схема здесь — церемония; пять строк ручной проверки честнее.
Второй: все записи идут через ограничения хранилища, чтение всегда из этого же хранилища, и бизнес-правила совпадают с ограничениями. Тогда форму данных уже держит слой хранения, и дублировать это в коде — значит завести второй артефакт, который разъедется с первым.
Если оба случая ваши — не начинайте. Если ни один — начинайте с инвентаризации границ.
Как проверить себя
Пять вопросов для быстрой самопроверки:
- Может ли значение попасть внутрь процесса мимо валидатора? Если да — вот и следующая дыра.
- Есть ли
asна данных извне? Каждый такой каст — обещание, которое никто не проверил. - Живёт ли проверка в другом файле, чем тип? Артефакты разъедутся.
- Валидируете ли вы один объект дважды на разных слоях? Это тот же тупик, только со схемами.
- Падает ли процесс при старте с плохим конфигом? Если нет, конфиг проверяет пользователь.
И вопрос, который стоит оставить открытым: кто в вашей системе прямо сейчас отвечает за форму данных — код, спека или ничья добросовестность? Пока ответ — третий, типы в рантайме не проверяет никто.