Миграция на TypeScript: как strict сломал прод на выходных
После включения strict в tsconfig сборка прошла зелёной, а в продакшене платёжный виджет начал падать с TypeError. Разбираем, где типы соврали и почему компилятор пропустил ошибку.

В пятницу вечером мы влили ветку с включённым strict в tsconfig.json. Сборка зелёная, все 1240 юнит-тестов прошли, деплой уехал на прод. В субботу утром мониторинг показал рост ошибок в платёжном виджете: TypeError: Cannot read properties of undefined (reading 'amount'), около 4% сессий на странице оплаты. Тесты не падали. Типы, по мнению компилятора, сходились. Ниже — как так вышло и что мы с этим сделали.
Проект — фронтенд на React, около 180 тысяч строк, полтора года постепенной миграции с JavaScript на TypeScript 5.4. Мигрировали файл за файлом, оставляя allowJs: true и .js-файлы рядом с .ts. Именно этот гибрид и стал источником инцидента.
Что именно упало
Платёжный виджет получал данные заказа из внутреннего API-клиента. Клиент был написан на TypeScript и типизирован честно:
// api/orders.ts, TypeScript 5.4
interface Order {
id: string;
amount: number;
currency: string;
}
export async function fetchOrder(id: string): Promise<Order> {
const res = await fetch(`/api/orders/${id}`);
return res.json(); // res.json() возвращает Promise<any>
}
Проблема в последней строке. res.json() в стандартной типизации fetch возвращает Promise<any>. Компилятор молча приводит any к Order — никакой проверки на рантайме нет. Если бэкенд под нагрузкой отдавал { error: "..." } вместо заказа, order.amount оказывался undefined, и виджет падал уже в React при попытке отформатировать сумму.
Компилятор проверяет то, что вы ему пообещали. Возвращаемый типPromise<Order>— это обещание разработчика, а не проверка данных.anyна границе с сетью выключает всю систему типов ниже по коду.
Почему strict ничего не поймал
Включение strict ожидаемо ловит null и undefined внутри вашего кода. Но у strict есть слепые зоны, и все они собрались в одной функции.
any проходит сквозь strict
Флаг strict не запрещает any. За это отвечает отдельный noImplicitAny, но он ловит только неявный any — например, нетипизированный параметр. Значение, которое библиотека сама вернула как any (результат res.json()), проходит везде без единого предупреждения.
Тесты работали на моках
Юнит-тесты мокали fetchOrder и возвращали корректный Order. Мок был типизирован тем же интерфейсом, поэтому тест проверял согласованность типов с самим собой, а не поведение на реальном ответе сервера. Реальный «битый» ответ в тестах не воспроизводился никогда.
Гибридные .js-файлы врали громче
Часть обвязки виджета оставалась в .js. При allowJs без checkJs эти файлы для компилятора — сплошной any. Данные из типизированного клиента проходили через нетипизированный слой и теряли типы окончательно.
Как чинили
Горячий фикс на проде — добавили проверку прямо в виджете и откатили strict с платёжного пути на время расследования. Дальше сделали правильно.
- Ввели рантайм-валидацию на границе с сетью. Взяли
zod(версия 3.x): схема валидирует ответ и одновременно выводит тип, так что дублироватьinterfaceне нужно. - Запретили
anyна этой границе через ESLint-правило, а не через компилятор. - Прогнали аудит всех вызовов
.json()и внешних SDK на предмет проглоченногоany.
// api/orders.ts после фикса, TypeScript 5.4, zod 3.x
import { z } from "zod";
const orderSchema = z.object({
id: z.string(),
amount: z.number(),
currency: z.string(),
});
// Тип выводится из схемы — один источник правды
export type Order = z.infer<typeof orderSchema>;
export async function fetchOrder(id: string): Promise<Order> {
const res = await fetch(`/api/orders/${id}`);
const data = await res.json();
// Бросит понятную ошибку с путём до битого поля,
// а не молча вернёт undefined ниже по коду
return orderSchema.parse(data);
}
Теперь битый ответ падает в одном предсказуемом месте с сообщением вида Expected number, received undefined at "amount", а не через три компонента в чужом коде форматирования.
Настройки tsconfig, которые стоило включить раньше
strict — это набор флагов. Для гибридного проекта важнее не он сам, а то, что вокруг него.
| Опция | Что делает | Наш случай |
|---|---|---|
strict | Включает группу строгих проверок (null-безопасность, неявный any и др.) | Был включён, но не спас от явного any из библиотеки |
checkJs | Проверяет типы и в .js-файлах | Был выключен — обвязка виджета молча теряла типы |
noUncheckedIndexedAccess | Добавляет undefined к результату доступа по индексу | Не входит в strict, включили отдельно |
Обратите внимание: noUncheckedIndexedAccess в набор strict не входит и включается отдельным флагом. Он бы поймал часть похожих проблем с доступом к массивам, но не наш случай с any — против any нет ни одного флага компилятора.
Границы: где TypeScript вам не поможет
Система типов TypeScript стирается при компиляции. На рантайме её нет. Всё, что приходит извне процесса, для типов — обещание, а не факт:
- ответы
fetch,axiosи любых HTTP-клиентов; - данные из
localStorage, куки,JSON.parse; - сообщения из
postMessageи WebSocket; - значения из нетипизированных или слабо типизированных SDK.
На каждой такой границе нужен рантайм-контроль: валидатор (zod, valibot) или хотя бы явные проверки полей. Внутри вашего кода после границы типам можно верить.
Что делать дальше
Если вы в середине миграции с JS на TS, начните не с массового включения strict, а с инвентаризации границ. Найдите все места, где данные входят в приложение, и поставьте там валидацию — это даст больше пользы, чем сотня переименованных .js в .ts. Затем включайте checkJs, чтобы оставшийся JavaScript перестал быть чёрной дырой для типов. Флаг strict оставьте на конец: он ловит ошибки в вашем коде, но не защищает границу с внешним миром — именно её ломает нагрузка в первую очередь.
В проде смотрите на долю рантайм-ошибок вида Cannot read properties of undefined в вашем error-трекере до и после включения валидации на границах. Это честный показатель того, что миграция реально снизила класс ошибок, а не просто перекрасила расширения файлов.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: TypeScript: tsconfig — strict и связанные флаги, Zod — документация
Частые вопросы
У меня TypeScript 4.x, а не 5.4 — совет про границы применим?
Разве strict не должен был поймать undefined?
Обязательно тащить zod ради этого? Можно проверками вручную?
А если валидация замедлит горячий путь?
Стоит ли включать strict сразу на всём проекте при миграции?
Почему тесты не поймали ошибку?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.