TypeScript Миграция JavaScript → TypeScript и производительность компилятора
0%

Миграция JavaScript → TypeScript и производительность компилятора

Миграция 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 покажет конкретные «горячие точки»: файл и строку, где проверка одного выражения заняла сотни миллисекунд.

Дерево решений

Что реально помогает

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 в монорепо.
  • Явные типы возврата у экспортов.
  • Нет барельных файлов на горячих путях.

Источники

Что дальше

Мы научились двигать кодовую базу вручную. Финальная глава — про то, как двигать её программно: Compiler API, ts-morph, кодмоды и собственные правила линтера, которые применяют решение сразу к тысячам файлов.

Compiler API, ts-morph и кодмоды

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов