Статья · Личный блог
Включаем strict mode в TypeScript на старом проекте
Как я внедряю strict mode в большой многолетний проект постепенно — по директориям, с CI-трещоткой, без полугодовой заморозки фич.
В каждом большом TypeScript-проекте, который мне доставался, стоял strict: false и комментарий с обещанием починить это позже. В облачном файловом менеджере это «позже» само так и не наступило. Рассказываю, как включить strict mode, не замораживая разработку фич на полгода.
На самом деле вам нужен strictNullChecks
strict — это связка из семи флагов, но реальные баги стабильно находит только один: strictNullChecks. Без него null и undefined присваиваются куда угодно, и система типов радостно одобряет ровно те ошибки, из-за которых вас будят в два часа ночи.
function initials(user: { name: string }) {
return user.name.split(' ').map((p) => p[0]).join('');
}
initials(users.find((u) => u.id === id)); // find может вернуть undefined — тихий краш без флага
С включённым strictNullChecks find возвращает User | undefined, и компилятор заставляет обработать промах. Остальные флаги — noImplicitThis, alwaysStrict, strictBindCallApply — почти бесплатны и срабатывают редко. Сначала включайте проверку на null: именно там зарыты все трупы.
Strict mode — это не вопрос стиля. Это разница между тем, знает ли компилятор, что ваши данные могут отсутствовать, или делает вид, что такого не бывает.
По директориям, а не глобальный флаг
Если на зрелой кодовой базе переключить strict: true в корне репозитория, получишь четырёхзначное число ошибок и пул-реквест, который никто не сможет отревьюить. Такой PR висит, протухает и откатывается. Так делать не надо.
Вместо этого я ограничиваю strict через project references или вложенные tsconfig и мигрирую по одной зоне за раз:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "strict": true },
"include": ["src/billing/**/*"]
}
Каждая директория становится единицей работы, которую один человек закрывает за один присест, а один ревьюер реально способен прочитать. Новый код пишется строгим с первого дня; старый конвертируется при касании. Листовые модули — утилиты, чистая логика — идут первыми: у них меньше всего зависимых и они вскрывают самые переиспользуемые дыры в типах.
Расползание any, которое тихо ломает строгость
Можно включить все строгие флаги и всё равно выкатывать баги с null, потому что один any отравляет всё, к чему прикасается. Значение с типом any молча поглощает то самое отсутствие, которое должен был поймать компилятор, и расползается по возвращаемым типам и деструктуризации без единой ошибки.
- Запретите явную лазейку через
@typescript-eslint/no-explicit-any; вместо неё заставляйте использоватьunknownплюс проверку-сужение. - Включите
noImplicitAny, чтобы нетипизированные параметры и выведенныйanyстали ошибками, а не невидимыми дырами. - Относитесь к сторонним пакетам
@typesс подозрением — кривой.d.tsвпрыскиваетany, который вы не писали. - Проверяйте касты:
as Foo— этоanyв костюме, аas unknown as Foo— то же самое, только честно признаётся.
Перед каждой вехой по strict mode я грепаю : any и as . Число ошибок врёт, если дыры типизированы как any; это баги, о которых компилятор согласился промолчать.
noUncheckedIndexedAccess как следующий уровень
Когда проверки на null зелёные, следующий реальный апгрейд — noUncheckedIndexedAccess. Он заставляет любое индексное чтение возвращать T | undefined, и это правда: arr[i] и record[key] могут промахнуться.
const config: Record<string, string> = load();
const region = config['region'].toUpperCase(); // ошибка: объект может быть undefined
Этот флаг шумнее и спорнее — тела циклов for (let i = 0; i < arr.length; i++) теперь жалуются, даже когда вы знаете, что i в пределах диапазона. Я всё равно его включаю. Трение сосредоточено в нескольких горячих местах, и каждое его срабатывание — это место, которое упало бы на кривых входных данных. Оставляю выключенным только для кода, который доказуемо безопасен по индексам и достаточно горячий, чтобы это было важно.
Как не дать трещотке проскользнуть назад
Миграция без нижней границы протекает. Через неделю после того, как вы починили директорию, кто-то добавляет // @ts-ignore, и счётчик снова растёт. Лечится это трещоткой — числом, которому разрешено только уменьшаться.
Два механизма, выбирайте один:
- CI-гейт на суммарное число ошибок под строгим конфигом. Сборка фиксирует текущее число как базовую линию; любой коммит, который его повышает, падает. Число монотонно убывает по мере конвертации директорий.
- Аллоулист строгих файлов: закоммиченный список путей, компилируемых со строгими настройками. Добавить файл — однострочный PR; убрать — нужна причина. Новые файлы по умолчанию попадают в строгий набор.
# уронить сборку, если строгих ошибок больше закоммиченной базовой линии
test "$(tsc -p tsconfig.strict.json --noEmit 2>&1 | grep -c 'error TS')" -le "$(cat .strict-baseline)"
Ещё я запрещаю голый @ts-ignore в пользу @ts-expect-error, который сам становится ошибкой, как только исходная проблема устранена, — так подавления убирают себя сами, а не накапливаются.
Что в итоге
В файловом менеджере миграция шла месяцами в фоне, ни разу не как отдельная заморозка. К концу примерно четверть кодовой базы стала строгой, включая каждый модуль, который касался денег или прав доступа. Что изменилось на практике:
- Целый класс продакшен-репортов «cannot read property of undefined» иссяк — они стали ошибками компиляции.
- Рефакторинг подешевел, потому что компилятор теперь указывает на каждого вызывающего, кого затрагивает изменение nullable-типа.
- Новые инженеры перестали гадать, может ли поле отсутствовать; тип говорит это прямо.
- Трещотка означала, что мы больше не пересматривали решение — строгость только росла, по одному PR за раз.
Урок, который я заново усваиваю снова и снова: чтобы начать, не нужна строгая кодовая база — нужно строгое направление и гейт, который отказывается давать ему развернуться вспять.