Миграция JS-проекта на TypeScript: что реально болит
Перевод живого JavaScript-проекта на TypeScript почти никогда не идёт по инструкции из README. Разбираем стратегии, цену strict-режима и места, где типизация ломается о реальный код.

Проект на 80 тысяч строк JavaScript, десяток разработчиков, релизы каждую неделю. Задача: перевести на TypeScript, не останавливая разработку и не переписывая всё за один спринт. Наивный путь — переименовать все .js в .ts и включить strict — даёт несколько тысяч ошибок компиляции за один прогон и парализует команду на месяц. Ниже — подходы, которые дают работающий результат, и их цена в времени и нервах.
Почему очевидный путь не работает
TypeScript устроен так, что валидный JavaScript почти всегда валидный TypeScript. Отсюда соблазн: включаем компилятор на весь код и правим ошибки. Проблема в том, что при strict: true компилятор одновременно предъявляет претензии к null-безопасности, неявным any, необъявленным свойствам и десятку других вещей. На большом коде это не список задач, а стена, которую невозможно разбить на осмысленные ревью.
Второй подводный камень — сторонние библиотеки без типов и внутренние модули, которые полагаются на динамику JavaScript: объекты, обрастающие свойствами в рантайме, функции с переменной сигнатурой, this, меняющий смысл по контексту. Компилятор их не понимает и требует явных решений там, где раньше код просто работал.
Миграция на TypeScript — это не разовая операция, а режим работы на несколько месяцев, где новый код уже строгий, а старый постепенно догоняет.
Три стратегии перехода
На практике встречаются три подхода. Они не взаимоисключающие: крупный проект обычно проходит через первый к третьему.
1. allowJs — сосуществование
Включаете в tsconfig.json опцию allowJs: true и checkJs: false. TypeScript начинает видеть .js-файлы, но не проверяет их. Вы добавляете .ts-файлы по одному, они получают типы, а старый код продолжает работать без изменений. Импорты между .ts и .js разрешены.
Цена: пока код смешанный, вы не получаете гарантий на границах. .ts-модуль импортирует .js-модуль и получает тип any на всё, что оттуда приходит. Типобезопасность иллюзорна ровно до тех пор, пока обе стороны границы не типизированы.
2. checkJs через JSDoc
Включаете checkJs: true и типизируете существующие .js-файлы комментариями JSDoc, не переименовывая их. Компилятор проверяет типы прямо в JavaScript.
// @ts-check
/**
* @param {number} priceCents
* @param {number} quantity
* @returns {number}
*/
function calculateTotal(priceCents, quantity) {
return priceCents * quantity;
}
Подход даёт проверку без изменения расширений и без ломки сборки. Полезен, если сборочный конвейер завязан на .js и переименование файлов дорого. Цена: JSDoc многословнее аннотаций, сложные обобщённые типы в нём выражать неудобно, а часть возможностей TypeScript в JSDoc недоступна или выражается неочевидно.
3. Пофайловая миграция со strict по частям
Самый управляемый вариант для большого проекта. Держите базовый tsconfig.json мягким, а строгие настройки включайте точечно. Начиная с TypeScript 4.9 можно указывать конкретные строгие флаги, а не только общий strict. Мигрируете модуль — включаете строгую проверку для него.
Здесь помогает утилита ts-migrate от Airbnb: она автоматически переименовывает файлы и расставляет заглушки // @ts-expect-error и : any там, где не может вывести тип. Получается компилируемый, но ещё не безопасный код, который дальше разгребается вручную. Проверьте актуальность и совместимость версии с вашим TypeScript в репозитории проекта перед внедрением.
Сравнение стратегий
| Критерий | allowJs | checkJs + JSDoc | Пофайловая .ts |
|---|---|---|---|
| Изменение сборки | минимальное | нет | требуется |
| Гарантии типов сразу | нет | частичные | по мигрированным файлам |
| Выразительность типов | полная в .ts | ограничена JSDoc | полная |
| Порог входа команды | низкий | средний | высокий |
| Кому подходит | старт миграции | legacy без пересборки | целевое состояние |
Где типизация ломается о реальный код
Инструменты переносят синтаксис, но не понимают динамику. Больнее всего в этих местах:
- Объекты, обрастающие свойствами в рантайме. Паттерн
const config = {}; config.timeout = 5000;компилятор встретит ошибкой: свойстваtimeoutнет в типе{}. Решение — объявить интерфейс заранее, а не наращивать объект по ходу. - Библиотеки без типов. Проверьте наличие пакета
@types/имя-пакетав реестре DefinitelyTyped. Если типов нет — придётся писать локальный файл деклараций.d.tsили объявить модуль какanyи жить с дырой. - Ответы API как
any.fetch().then(r => r.json())возвращаетPromise<any>. Соблазн разложить это по коду и потерять типобезопасность на всём, что пришло с бэкенда. Типизируйте ответ явно и валидируйте на границе. - Мутирующий
this. Старый код с функциями-конструкторами и подменой контекста черезcall/applyкомпилятор типизирует с трудом. Такие места чаще переписывают на классы, чем описывают типами.
Валидация на границе вместо доверия к типам
Ключевое, что понимают не сразу: TypeScript проверяет типы на этапе компиляции и полностью исчезает в рантайме. Ответ API, помеченный как interface User, на деле может прийти сломанным — компилятор об этом не узнает. Поэтому на границах системы (HTTP, чтение файлов, парсинг) типов недостаточно, нужна рантайм-валидация схемой. Схемные библиотеки вроде Zod дают одновременно проверку в рантайме и вывод типа из схемы. Выбор конкретной библиотеки — отдельная тема, здесь фиксируем сам принцип.
Как включать строгость, чтобы не утонуть
Флаг strict — это набор из нескольких проверок. Включать их по одному дешевле, чем всё разом. Порядок от наименее болезненного к наиболее:
noImplicitAny— заставляет явно типизировать то, что компилятор не смог вывести. Обычно даёт больше всего ошибок, но каждая правится механически.strictNullChecks— самый ценный и самый трудозатратный флаг:nullиundefinedперестают быть частью каждого типа. Ловит целый класс ошибок «обращение к свойству уundefined».- Остальные строгие флаги (
strictFunctionTypes,strictBindCallApplyи другие) — точечно, они дают заметно меньше ошибок.
Точный состав флагов, входящих в strict, сверяйте в разделе Compiler Options официальной документации: набор пополнялся между версиями.
Что делать дальше
После того как проект компилируется и новый код пишется строго, наметьте три вещи. Первое — включите в CI запрет на новые // @ts-ignore и явные any: без этого миграция откатывается назад тихо и незаметно. Второе — заведите метрику покрытия типами, например через type-coverage, и следите, чтобы процент рос, а не падал. Третье — разберитесь с рантайм-валидацией на границах API до того, как накопится код, слепо доверяющий типам с бэкенда. Строгость компилятора — только половина защиты; вторая половина живёт в рантайме.
Локализатор видео: Перевод и озвучка видео на другой язык с укладкой в исходный тайминг. Глоссарий терминов, отметки проблемных реплик.
Спросить за 250 ₽Источники: TypeScript Handbook — Migrating from JavaScript, TypeScript — tsconfig Compiler Options
Частые вопросы
А если у меня другая версия TypeScript?
Сколько времени занимает миграция большого проекта?
Что делать, если у библиотеки нет типов?
Даёт ли TypeScript защиту в рантайме?
Стоит ли переписывать динамический код или лучше подавлять ошибки?
А если после включения strictNullChecks появились тысячи ошибок?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.