Миграция JavaScript → TypeScript и производительность компилятора
Большинство разработчиков встречают TypeScript не на пустом проекте, а на живом JavaScript-коде: 200 тысяч строк, десять человек в команде, релиз каждый день. Остановить разработку на «месяц переписывания» нельзя. Хорошая новость: язык спроектирован ровно под этот сценарий — постепенная типизация позволяет двигаться файл за файлом, не ломая сборку.
Вторая половина главы — про обратную сторону успеха. Когда типов становится
много, компилятор начинает думать: tsc идёт минуты, редактор подтормаживает на
автодополнении. Это тоже инженерная задача с измерением и профилированием, а не
повод «отключить строгость».
Стратегии перехода
- Большой взрыв. Переименовать всё разом, залить
anyдыры, влить одним PR. Работает на проекте до нескольких тысяч строк. На большом — конфликтует со всеми ветками команды и не проходит ревью. - Только новый код на TypeScript. Дёшево стартовать, но старый код никогда не
типизируется, и граница между мирами становится постоянным источником
any. - Рекомендуемый путь:
checkJs+ JSDoc → переименование от листьев → строгость по одному флагу. Каждый шаг — маленький PR, сборка всегда зелёная.
Перед стартом нужен фундамент: работающая сборка, проходящие тесты и CI, который падает на регрессии (см. главу про тестирование). Миграция без тестов — это рефакторинг вслепую.
Железное правило: не смешивайте миграцию с рефакторингом. Соблазн «раз уж я здесь, перепишу эту функцию» превращает безопасное механическое изменение в рискованное. Сначала типы один в один, поведение не трогаем; улучшения — отдельным PR.
Шаг 1: типы без переименования файлов
Первый шаг делается вообще без .ts. Компилятор умеет проверять JavaScript, а
типы можно писать в JSDoc-комментариях.
// tsconfig.json на старте миграции
{
"compilerOptions": {
"allowJs": true, // .js-файлы попадают в программу
"checkJs": true, // и проверяются (можно включать пофайлово через // @ts-check)
"noEmit": true, // пока только проверяем; сборка остаётся прежней
"strict": false, // строгость включим позже, по одному флагу
"target": "ES2022",
"moduleResolution": "bundler"
},
"include": ["src"]
}
// src/pricing.js — обычный JS, но полностью типизированный
// @ts-check
/**
* @typedef {object} OrderItem
* @property {string} sku
* @property {number} price цена за единицу в копейках
* @property {number} qty
*/
/** @type {import("./config.js").Discounts} */
const discounts = loadDiscounts();
/**
* Считает итоговую сумму заказа.
* @param {readonly OrderItem[]} items
* @param {{ vat?: number }} [opts]
* @returns {number}
*/
export function total(items, opts = {}) {
const sum = items.reduce((acc, i) => acc + i.price * i.qty, 0);
return Math.round(sum * (1 + (opts.vat ?? 0)));
}
Это полноценная типизация: работают вывод типов, дженерики (@template),
@satisfies, импорт типов из других файлов. Многие библиотеки (например, Svelte
в своё время, часть пакетов Node-экосистемы) сознательно живут так, чтобы не иметь
шага сборки.
Включать checkJs сразу на весь проект — стресс: посыплются сотни ошибок.
Практичнее наоборот: "checkJs": false глобально и // @ts-check в шапке файлов,
которые уже привели в порядок. Так вы контролируете темп.
Шаг 2: переименование от листьев к корню
Дальше файлы переводятся в .ts. Порядок важен: начинайте с листьев — модулей
без внутренних зависимостей (утилиты, константы, чистые функции). Их типы
поднимутся вверх по графу и облегчат следующие шаги. Если начать с точки входа,
всё, что она импортирует, будет any, и вы напишете типы дважды.
# найти листья графа зависимостей — кандидатов на перевод первыми
pnpm dlx madge --json src | jq -r 'to_entries[] | select(.value|length==0) | .key'
Дыры на этом этапе неизбежны — важно, чтобы они были заметными и временными:
// ХОРОШО: ошибка ожидается; когда её не станет, компилятор скажет об этом
// @ts-expect-error legacy-модуль без типов, задача PLAT-412
import { legacyParse } from "../vendor/parser.js";
// ПЛОХО: молчит вечно, даже когда проблема давно исчезла
// @ts-ignore
import { legacyParse } from "../vendor/parser.js";
Разница принципиальная: @ts-expect-error сам становится ошибкой, когда
подавлять больше нечего. Это самоочищающийся долг. Запретите @ts-ignore
правилом @typescript-eslint/ban-ts-comment и требуйте описание причины.
Шаг 3: строгость по одному флагу
strict: true — это восемь проверок разом. Включать их в большом проекте нужно
по одной, в порядке «дешёвая → дорогая»:
| Порядок | Флаг | Что чинит | Обычный объём правок |
|---|---|---|---|
| 1 | noImplicitThis |
this неизвестного типа |
малый |
| 2 | strictBindCallApply |
неверные аргументы bind/call |
малый |
| 3 | strictFunctionTypes |
небезопасная вариантность колбэков | средний |
| 4 | noImplicitAny |
параметры без типов | большой |
| 5 | strictNullChecks |
главный приз: null/undefined |
самый большой |
| 6 | strictPropertyInitialization |
неинициализированные поля классов | средний |
| 7 | noUncheckedIndexedAccess |
arr[i] может быть undefined |
средний |
| 8 | exactOptionalPropertyTypes |
различие «нет поля» и undefined |
средний |
strictNullChecks даёт 80% пользы и 80% работы. Именно ради неё всё затевается:
без неё TypeScript не защищает от самой частой ошибки в проде.
Чтобы прогресс не откатывался, нужен храповик — механизм, который разрешает уменьшать число ошибок, но не увеличивать:
# сколько процентов кода реально типизировано (any не считается)
pnpm dlx type-coverage --detail --at-least 92
# betterer фиксирует «снимок» текущих проблем и падает при ухудшении
pnpm dlx betterer
Полезные метрики в дашборде миграции: доля файлов .ts, процент type-coverage,
число any, число @ts-expect-error, число файлов с // @ts-nocheck. Их стоит
печатать в CI — команда видит движение, и это лучший мотиватор.
Инструменты в помощь: ts-migrate от Airbnb (массовое переименование и расстановка заглушек), typescript-strict-plugin (строгость пофайлово через комментарий), knip (найти мёртвый код, который не надо мигрировать вовсе — самый дешёвый способ уменьшить объём работы).
Почему tsc тормозит
Компилятор делает не «перевод», а полный анализ программы: строит граф файлов,
связывает символы, сравнивает типы структурно. Структурное сравнение — самая
дорогая часть: чтобы понять, совместим ли A с B, нужно рекурсивно сопоставить
их поля, а результаты кешируются далеко не всегда.
Работает он в одном потоке. Отсюда простое следствие: типы имеют цену, и изредка она становится заметной.
Сначала измерьте
Как и в треке про производительность, правило номер один — не гадать.
# Сводка: сколько файлов, типов, инстанциаций и куда ушло время
pnpm exec tsc --noEmit --extendedDiagnostics
# Подробный трейс для анализа: какие именно места дорогие
pnpm exec tsc --noEmit --generateTrace ./trace
pnpm dlx @typescript/analyze-trace ./trace
# Почему модуль не находится / где компилятор ищет файлы
pnpm exec tsc --noEmit --traceResolution | grep "my-package"
Что читать в выводе --extendedDiagnostics:
| Показатель | О чём говорит |
|---|---|
Files |
размер программы. Тысячи лишних файлов — проблема include/types |
Types, Instantiations |
сложность типового уровня. Миллионы инстанциаций — где-то дженерик-монстр |
Program time |
чтение с диска и резолвинг модулей |
Check time |
собственно проверка типов — обычно основная часть |
Memory used |
близко к лимиту Node — будет своп и деградация |
analyze-trace покажет конкретные «горячие точки»: файл и строку, где проверка
одного выражения заняла сотни миллисекунд.
Дерево решений
в extendedDiagnostics?"} B -->|"Files"| C["Лишние файлы в программе"] B -->|"Instantiations"| D["Дорогие типы"] B -->|"Program time"| E["Резолвинг и I/O"] B -->|"Всё сразу"| F["Один гигантский проект"] C --> C1["Сузить include, exclude тестов и dist"] C --> C2["Указать types вместо всех @types"] C --> C3["skipLibCheck: true"] D --> D1["Аннотировать возврат экспортируемых функций"] D --> D2["Упростить условные и рекурсивные типы"] D --> D3["Разбить огромные union и мапы"] E --> E1["Убрать барельные index.ts"] E --> E2["import type вместо обычного импорта"] E --> E3["Не мешать paths и exports без нужды"] F --> F1["incremental и tsBuildInfo"] F --> F2["Project references, composite"] F --> F3["Кеш сборки в CI: turbo, nx"]
Что реально помогает
1. Инкрементальность. Компилятор сохраняет состояние программы и при следующем запуске проверяет только изменённое:
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./node_modules/.cache/tsbuildinfo"
}
}
В CI кешируйте этот файл вместе с node_modules — повторные прогоны ускоряются
кратно.
2. Project references. Монорепо разбивается на проекты с явными связями;
tsc --build собирает их в правильном порядке и пропускает неизменённые:
// packages/api/tsconfig.json
{
"compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" },
"references": [{ "path": "../contracts" }] // зависим от пакета контрактов
}
Ключевой эффект: зависимые проекты видят друг друга через сгенерированные .d.ts,
а не через исходники, — компилятору не нужно перепроверять чужой код.
3. Меньше файлов в программе. Проверьте, что тесты, скрипты и dist не
попадают в include продуктового tsconfig. Ограничьте types (см.
главу про модули).
4. Аннотации на границах. Явный тип возврата у экспортируемой функции избавляет компилятор от повторного вывода в каждом месте использования — на больших графах это заметно. Заодно улучшает сообщения об ошибках.
// Компилятор выводит тип заново в каждом импортирующем файле
export function buildIndex(rows: Row[]) { /* ... */ }
// Тип объявлен один раз — дешевле и стабильнее как публичный контракт
export function buildIndex(rows: Row[]): Map<string, Row[]> { /* ... */ }
5. Умеренность в типовой магии. Рекурсивные условные типы, парсинг строк на
уровне типов, Omit поверх Omit поверх огромного интерфейса — красиво, но
дорого. Если analyze-trace показывает на такой тип, упростите его или замените
явным описанием. То же предупреждение уже звучало в
главе про продвинутые типы: сложный тип
— это долг, теперь ещё и по времени сборки.
6. Разделение обязанностей. Проверка типов (tsc --noEmit) и сборка
(esbuild/swc) — разные шаги; в CI они идут параллельно. Это уже обсуждалось в
главе про тулчейн, но именно на больших
проектах даёт наибольший выигрыш.
Когда тормозит редактор, а не сборка
Языковой сервер (tsserver) — тот же компилятор, работающий инкрементально на
открытых файлах. Симптомы «автодополнение думает секунду» лечатся так же
(меньше файлов, меньше сложных типов), плюс:
- в VS Code откройте
TypeScript: Open TS Server Logи посмотрите, на что уходит время; - проверьте, что в проекте один
tsconfig, а не десяток пересекающихся; - отключите на время плагины редактора — иногда виноват не TypeScript.
Что впереди
Команда TypeScript переписывает компилятор на Go (typescript-go) — заявленная цель для будущей версии языка — примерно десятикратное ускорение проверки и многопоточность. Это не отменяет гигиены из этой главы: раздутая программа останется раздутой, просто проверяться будет быстрее.
Чек-лист
Миграция:
- Тесты и CI зелёные до начала работ.
-
allowJs+noEmit, сборка не изменилась. - JSDoc и
// @ts-checkв ядре утилит. - Переименование от листьев, маленькими PR.
-
@ts-expect-errorс номером задачи вместо@ts-ignore. - Строгие флаги по одному,
strictNullChecks— главная цель. - Храповик (
type-coverage/betterer) в CI.
Производительность:
- Есть цифры
--extendedDiagnosticsдо и после. -
skipLibCheck, суженныеtypesиinclude. -
incremental+ кешtsbuildinfoв CI. - Project references в монорепо.
- Явные типы возврата у экспортов.
- Нет барельных файлов на горячих путях.
Источники
- TypeScript Wiki: Performance — официальный свод рекомендаций по скорости компиляции.
- Handbook: Migrating from JavaScript и JSDoc Reference.
- Project References.
- @typescript/analyze-trace — разбор трейсов компиляции.
- type-coverage и betterer — метрики и храповик.
Что дальше
Мы научились двигать кодовую базу вручную. Финальная глава — про то, как двигать её программно: Compiler API, ts-morph, кодмоды и собственные правила линтера, которые применяют решение сразу к тысячам файлов.