Статья · Личный блог

Включаем 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 молча поглощает то самое отсутствие, которое должен был поймать компилятор, и расползается по возвращаемым типам и деструктуризации без единой ошибки.

Перед каждой вехой по 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, и счётчик снова растёт. Лечится это трещоткой — числом, которому разрешено только уменьшаться.

Два механизма, выбирайте один:

# уронить сборку, если строгих ошибок больше закоммиченной базовой линии
test "$(tsc -p tsconfig.strict.json --noEmit 2>&1 | grep -c 'error TS')" -le "$(cat .strict-baseline)"

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

Что в итоге

В файловом менеджере миграция шла месяцами в фоне, ни разу не как отдельная заморозка. К концу примерно четверть кодовой базы стала строгой, включая каждый модуль, который касался денег или прав доступа. Что изменилось на практике:

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