Типизированная эволюция: Стратегия постепенной миграии JavaScript-проекта на TypeScript

Многие команды сталкиваются с дилеммой: как интегрировать TypeScript в унаследованный JavaScript-проект, не останавливая разработку и не переписывая всё с нуля. Ошибочный подход «big bang» часто приводит к месяцам заморозки фич и болезненному рефакторингу. Рассмотрим инженерно оправданный метод миграции, совместимый с активной разработкой.

Зачем тратить ресурсы?

TypeScript – не просто аннотации типов. Это:

  1. Контракт для модулей: Четкие интерфейсы исключают классы ошибок вроде undefined is not a function на этапе компиляции.
  2. Документация в коде: Типы заменяют 80% JSDoc и остаются актуальными.
  3. Покрытие легаси-кода: Позволяет обнаруживать скрытые баги при добавлении типов к старым модулям.

Пример:

javascript
// До  
function calculateTotal(items) {  
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);  
}  

// Риск: item без price, quantity или массив undefined  

Типизированная версия сразу выявляет риски:

typescript
interface CartItem {  
  price: number;  
  quantity: number;  
}  

function calculateTotal(items: CartItem[]): number { ... }  

Инкрементальная стратегия

Ключевой принцип: «Разрешить, затем усилить».

Шаг 1: Настройка среды

Установите зависимости:

bash
npm install typescript @types/node --save-dev  

Создайте tsconfig.json с критически важными параметрами:

json
{  
  "compilerOptions": {  
    "allowJs": true,     // Разрешить .js файлы  
    "checkJs": false,    // Не проверять JS сразу  
    "outDir": "./dist",  
    "module": "ESNext",  
    "target": "ES2020",  
    "strict": false,     // Пока без строгой проверки  
    "esModuleInterop": true  
  },  
  "include": ["src/**/*"]  
}  

Добавьте в package.json:

json
"scripts": {  
  "build": "tsc",  
  "dev": "tsc --watch"  
}  

Шаг 2: Захват файлов по принципу «островков»

Переименуйте файлы в .ts по мере их доработки. Не делайте массового переименования! Начните с:

  • Новых модулей
  • Файлов с высокой цикломатической сложностью
  • Утилит, используемых в 10+ местах

Пример преобразования:

typescript
// legacy-utils.js -> legacy-utils.ts  
export function formatDate(date) { ... }  

Становится:

typescript
export function formatDate(date: Date | string): string {  
  // ...  
}  

Шаг 3: Типизация через JSDoc (для временного решения)

Для файлов, оставшихся .js, используйте JSDoc-аннотации. TypeScript их распознает:

javascript
/**  
 * @param {number[]} values  
 * @returns {number}  
 */  
function calculateAverage(values) { ... }  

Добавьте проверку таких файлов через // @ts-check в первой строке.

Шаг 4: Работа с внешними зависимостями

Проблема: Библиотека без типов в @types. Решение – декларации .d.ts:

typescript
// types/legacy-lib.d.ts  
declare module "legacy-lib" {  
  export function riskyOperation(data: any): void;  
}  

Для модулей с неявным контрактом используйте утилиты:

typescript
// Упрощенная типизация для 300-строчного jQuery-плагина  
type JQueryPlugin = (element: HTMLElement, options?: Record<string, unknown>) => void;  
declare const initCustomCarousel: JQueryPlugin;  

Шаг 5: Активация строгого режима

После покрытия 70+% кода типами включайте постепенно строгие проверки в tsconfig:

json
{  
  "strict": true,  
  "strictNullChecks": true,  
  "noImplicitAny": false // Сначала отключено  
}  

Пошагово исправляйте ошибки, подключая правила:

bash
npx tsc --strictNullChecks --noEmit src/modules/data-layer  

Реальные сложности и решения

  • Глобальные переменные:
typescript
declare global {  
  interface Window {  
    _env: { API_URL: string };  
  }  
}  
  • Динамические ключи объектов:
typescript
type User = Record<string, unknown>; // Плохо  
type User = {  
  id: number;  
  email: string;  
  [key: `custom_${string}`]: string; // Динамические кастомные поля  
};  
  • Миграция тестов: Используйте ts-node с AVA/Jest:
json
// jest.config.js  
module.exports = {  
  preset: "ts-jest",  
  testMatch: ["**/*.test.{js,ts}"] // Запускает и JS, и TS тесты  
};  

После миграции: что дальше?

  1. Включите noImplicitAny и strictPropertyInitialization.
  2. Замените JSDoc аннотации на нативные типы.
  3. Внедрите утилиты типа вместо ручных проверок:
typescript
// Вместо: function getValue(): string | null  
function getValue<T>(): T | null { ... }  

Типизация – не религия, а инженерный инструмент. Грамотная миграция сокращает количество ошибок в production на 15-38% (исследования Microsoft). Начните с критических модулей, терпеливо добавляйте типы к легаси и помните: даже частичная типизация лучше, чем её отсутствие.

Итоговая рекомендация: Файл считается мигрированным, когда:

  • Он в .ts
  • Проходит strict:true
  • Имеет ≥ 90% покрытие типов (отслеживается через typescript-coverage-report)

Инкрементальный подход позволяет получать пользу от TypeScript уже через неделю, а не через квартал.