За 6 недель Claude Code преобразовал 200K строк JS в strict TypeScript. Не переименование файлов, а настоящая типизация: интерфейсы, строгие null-чеки, перехваченные баги в проде. Тут разбор реального кейса с цифрами, ошибками агента и главным вопросом: стоит ли вам это повторять?

1. Зачем мигрировали

Кодовой базе было 6 лет. Node.js-монолит на 200K строк, который обслуживал 50K DAU. Восемь разработчиков за эти годы оставили след: файлы с JSDoc, файлы без него, 200+ комментариев // @ts-ignore от попытки миграции в 2022 году, которая дошла до 15% и остановилась.

Боль была конкретная: 30% каждого спринта уходило на отладку ошибок, которые TypeScript поймал бы при компиляции. Null reference в проде. API-ответы с неожиданной структурой. Рефакторинг любого модуля превращался в игру в минёра.

Статистика за последние 12 месяцев до миграции:

  • 4–6 type-related багов на спринт

  • 3 недели онбординга нового разработчика

  • Один инцидент в проде на каждые 2 месяца с причиной «неожиданный null»

Всё это хорошо известно. Непонятно было другое: как мигрировать не замораживая разработку на полгода.

Ручная оценка: 2000+ человеко-часов. При команде в 8 человек — больше 3 месяцев работы только над типами, если заморозить фичи. Нереально.

2. Почему Claude Code, а не ручная миграция

Первый инструмент который приходит в голову — codemods. ts-migrate от Airbnb, jscodeshift. Мы пробовали. Они умеют переименовывать файлы и расставлять any везде где нет явного типа. Это не миграция, это просто смена расширения с легализованным any в каждой функции.

Проблема в том, что тип функции невозможно вывести статически не зная контекста. Вот простой пример:

// src/utils/format-price.js
function formatPrice(value, currency) {
  if (!value) return '—';
  return ${value.toFixed(2)} ${currency};
}

Codemod поставит any, any. Claude прочитает 15 мест где эта функция вызывается и выведет:

function formatPrice(value: number | null | undefined, currency: string): string {
  if (!value) return '—';
  return ${value.toFixed(2)} ${currency};
}

Это разница между формальным выполнением и пониманием кода.

Ключевое ограничение, которое нужно понять до старта: Claude не запускает ваш код. Он рассуждает о типах по тексту. Это означает, что для динамических паттернов (eval, runtime-зависимые типы, магия через Proxy) он будет ошибаться. Об этом подробнее в разделе 11.

3. Подготовка кодовой базы

Это 40% успеха. Большинство команд пропускают этот шаг и потом жалуются что «Claude ставит any везде». Не ставит — просто нет контекста.

Шаг 1: tsconfig.json для миграции

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "strict": false,
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/*/"]
}

allowJs: true — JS и TS файлы живут вместе. strict: false — затягивать будем постепенно. checkJs: false — не проверяем старый JS, только новый TS.

Сразу добавьте tsc --noEmit в CI. На первом этапе он ловит ноль ошибок — это нормально. Инфраструктура уже есть.

Шаг 2: CLAUDE.md для миграционного проекта

Это не опционально. Без него Claude будет делать то что кажется ему правильным — а не то что нужно вам.

Migration Rules
Convert one module at a time, never mix migration with featuresPrefer explicit types over inference when in doubtIf you cannot infer the type, use unknown not anyAdd // MIGRATION: reason when using type assertions (as)Do not refactor logic during conversion — types onlyIf a function has side effects that depend on runtime values,  flag it with // MIGRATION: needs manual review

Правило «не рефакторить логику» — самое важное. Без него Claude будет попутно «улучшать» код, и вы не сможете отличить баг от его улучшения в diff.

Шаг 3: src/types/ с boundary types

До того как отдавать что-либо Claude, создайте типы для границ системы: API-ответы, модели БД, shared interfaces. Это даёт каждому конвертируемому файлу что-то, на что можно опереться.

// src/types/api.ts
interface User {
  id: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  createdAt: Date;
  profile: UserProfile | null;
}
interface ApiResponse {
  data: T;
  meta: { total: number; page: number; perPage: number };
}

Правило: типизировать границы системы раньше внутренностей. API и DB-модели — первые, бизнес-логика — последняя.

4. Стратегия батчей

«Отдай Claude весь проект и пусть переконвертирует» — проверенный путь к катастрофе. Context window лопается на 800+ строках, агент начинает путать типы из начала файла с концом, ставит any там где раньше справлялся.

Правило: один модуль = один PR = один батч.

Приоритизация модулей: leaf modules первые. Это файлы без внутренних импортов — утилиты, хелперы, валидаторы. Они самодостаточны, конвертируются без побочных эффектов.

Неделя 1-2:  utils/, helpers/, validators/    ← leaf, начало
Неделя 3-5:  services/                         ← зависят от types/
Неделя 6-8:  controllers/, routes/             ← зависят от services/
Неделя 9-10: core/, app.ts                     ← последние

Размер батча: 5–15 файлов. Больше — Claude теряет нить.

Еженедельный scorecard помогает не потерять мотивацию:

#!/bin/bash
# migration-stats.sh
JS_COUNT=$(find src -name ".js" | wc -l)
TS_COUNT=$(find src -name ".ts" -o -name ".tsx" | wc -l)
TOTAL=$((JS_COUNT + TS_COUNT))
PCT=$((TS_COUNT  100 / TOTAL))
echo "JS: $JS_COUNT | TS: $TS_COUNT | Progress: $PCT%"
echo ""
echo "Strict mode errors:"
npx tsc --noEmit --strict 2>&1 | grep "error TS" | wc -l

Запускали каждую пятницу, скидывали в Slack. Видеть как 12% → 45% → 78% — это работает на мотивацию лучше любого митинга.

Практическое правило: PR с миграцией должен быть скучным. Reviewer смотрит на diff и думает «ну да, types добавили». Если PR интересный — значит Claude что-то не то сделал.

5. Промпт-паттерны которые сработали

Это самый ценный раздел. Промпты которые работают в продакшене, не в демо.

Промпт #1 — Базовая конвертация с контекстом

Convert src/utils/format-price.js to TypeScript.
Context: this function is called in:
src/components/ProductCard.jsx (line 34)src/api/checkout.js (line 112)src/reports/revenue.js (line 78, 89)
Rules from CLAUDE.md:
no any — use unknown if type is unclearadd // MIGRATION: reason for type assertionsdo not refactor logic, types only
After conversion, list any places where you had to make
a judgment call about the type.

Последняя строчка важна: Claude честно скажет «я решил что это string, потому что видел только строковые вызовы, но есть строка 89 в revenue.js которая мне непонятна».

Промпт #2 — Поиск типа через использование

I need to type the return value of getUserData() in
src/services/user.service.js.
Look at every place where getUserData() is called across
the codebase. For each call site, show me:
1. The file and line
2. How the result is used
3. What TypeScript type this implies
Then suggest the return type.

Это работает на 200K строк потому что у Claude есть codebase как контекст — он буквально ищет по файлам.

Промпт #3 — Восстановление после ошибки

In the last conversion of format-price.ts you changed
behavior on line 23: the original checked !value
(catches null, undefined, 0, ''), your version checks
value === null || value === undefined (misses 0 and '').
Revert ONLY that logic change. Keep all type annotations.
Explain why the original check was correct.

Конкретность — ключ. «Ты что-то сломал» не работает. «На строке 23 ты изменил логику вот так, верни как было» — работает.

Промпт #4 — Batch check после пачки файлов

Run through the TypeScript errors in the last migration batch
(src/utils/). For each error:
Is it a type annotation gap I need to fill?Or is it a real logic bug you found during conversion?
For real bugs: describe what the bug is and whether it
exists in the original JS.

Один из таких прогонов нашёл баг в user.service.js который жил в проде полтора года.

Инсайт: Claude находит баги не как цель, а как побочный эффект типизации. Это одна из главных ценностей миграции с AI.

6. Что Claude сломал

Честная часть. Без этого статья была бы рекламой.

Случай 1: Тихое изменение поведения

// Оригинал: src/utils/concat-name.js
function concatName(first, last) {
  return (first || '') + ' ' + (last || '');
}

Claude сконвертировал:

// После конвертации
function concatName(first: string | null, last: string | null): string {
  return ${first} ${last};
}

Логически «правильно» — типы проставлены верно. Но поведение изменилось: null теперь рендерится как строка "null" вместо пустой строки. В продакшене это сломало отображение имён пользователей у которых не заполнено поле.

Поймали через integration-тест который зафиксировал снапшот вывода.

Случай 2: Неправильный вывод типа из большинства

Функция возвращала string | number в зависимости от env-переменной PRICE_FORMAT. Claude посмотрел на 47 call sites, в 46 из них тип использовался как string, и поставил string.

Сорок седьмой кейс — метрика в Grafana которая ждала number. Падала раз в неделю с NaN.

Случай 3: Потеря контекста в больших файлах

Файл 800 строк. Тип из начала файла к середине Claude «терял» — переопределял его менее конкретным вариантом. Решение: файлы больше 300 строк конвертировать частями, по 150–200 строк за раз.

Главный урок: Каждый migration PR обязан проходить review у человека. Не формально — реальное diff-review с вопросом «изменилась ли логика?» Автоматика этого не поймает, потому что тесты проверяют поведение, а не намерение.

7. Тесты как safety net

Без тестов миграция с AI — это Russian roulette. Красивый TypeScript который ломает логику.

Главный инсайт: integration-тесты важнее unit перед AI-миграцией. Unit-тесты проверяют что функция делает одно конкретное действие. Integration-тесты фиксируют поведение модуля целиком — именно это и меняет Claude когда «улучшает» код.

Стратегия простая: перед тем как отдавать модуль Claude, покрой его snapshot-тестом.

// Перед миграцией: фиксируем текущее поведение
describe('formatPrice', () => {
  it('snapshot: existing behavior', () => {
    expect(formatPrice(10.5, 'USD')).toMatchSnapshot();
    expect(formatPrice(null, 'USD')).toMatchSnapshot();
    expect(formatPrice(0, 'EUR')).toMatchSnapshot();
    expect(formatPrice(undefined, 'RUB')).toMatchSnapshot();
  });
});

После конвертации тот же тест должен пройти. Если снапшот изменился — Claude изменил поведение. Разбирайся почему.

Три правила:

  1. Если у модуля нет тестов — конвертируй вручную. Не отдавай Claude то, что не можешь проверить. Исключение: чистые utility-функции с очевидной логикой.

  2. Тесты конвертируй последними, отдельным PR. Мы сначала хотели конвертировать всё разом — исходники и тесты. Не делайте так. Нетипизированные тесты проверяют типизированный код — это нормально. Зато если тест упадёт, ты точно знаешь что сломал Claude, а не то что тест написан криво.

  3. any в тестах = сигнал тревоги. Если после конвертации в тестовом файле появился any — это значит что тип в источнике недостаточно конкретный.

После того как мы добавили обязательные snapshot-тесты перед каждым батчем, количество случаев «Claude тихо сломал логику» упало с 3-4 за неделю до нуля за последние 4 недели миграции.

8. CI/CD изменения

Миграция без CI-закрепления — это строительство без фундамента. Через месяц кто-нибудь добавит .js файл «временно» и всё пойдёт откатываться.

Шаг 1: Guard против новых JS-файлов

# .github/workflows/typescript-guard.yml
name: No new JS files  run: |
    NEW_JS=$(git diff --name-only origin/main \
      | grep '\.js$' \
      | grep -v '\.config\.js$' \
      | grep -v '\.eslintrc\.js$')
    if [ -n "$NEW_JS" ]; then
      echo "New .js files detected. All new code must be TypeScript."
      echo "$NEW_JS"
      exit 1
    fi

Шаг 2: Поэтапное включение strict-флагов

Не включай strict: true сразу — это сотни ошибок, которые демотивируют команду. Включай по одному:

// Неделя 8: первый флаг
{ "strictNullChecks": true }
// Результат у нас: 847 ошибок → 2 недели → +3 реальных prod-бага найдено
// Неделя 10
{ "noImplicitAny": true }
// Результат: 312 ошибок → 1 неделя → все function parameters
// Неделя 11
{ "strictFunctionTypes": true }
// Результат: 56 ошибок → 3 дня
// Неделя 12: финал
{ "strict": true }
// Результат: 23 edge-case ошибки → 2 дня

Шаг 3: ESLint запрет any — после 100% TS

{
  "@typescript-eslint/no-explicit-any": "error",
  "@typescript-eslint/no-unsafe-assignment": "error",
  "@typescript-eslint/no-unsafe-member-access": "error"
}

И последнее: добавь вывод прогресса в CI. Мы показывали процент конвертации в каждом PR-check — это маленькая деталь, которая сильно работает на мотивацию.

9. Метрики после миграции

Прошло 6 месяцев после финала. Вот реальные цифры:

Метрика

До миграции

После

Δ

Type-related баги на спринт

4–6

0–1

-85%

Время онбординга нового разработчика

3 нед

1.5 нед

-50%

Уверенность команды в рефакторинге (опрос, 10-балльная шкала)

3.2

8.1

+153%

Catch rate в CI

~40%

~95%

+137%

Время code review сложного PR

~45 мин

~20 мин

-55%

Время сборки

12 сек

18 сек

+50%

Последняя строчка — да, build замедлился на 50%. Приняли без раздумий.

Одна цифра которой нет в таблице и которую невозможно померить количественно: разработчики перестали бояться трогать чужой код. До миграции «PR в user-сервис» звучало как предупреждение. После — это просто PR.

Неожиданный бонус: strictNullChecks нашёл 3 производственных бага которые мы не искали. Один — race condition: профиль пользователя мог быть null первые 200мс после регистрации. Мы тихо глотали ошибку несколько месяцев. Второй — функция в модуле платежей принимала amount: number но в одном flow прилетал string из FormData. TypeScript это поймал сразу, а runtime просто умножал строку на 1 и получал NaN в сумме заказа. Третий баг нашёл разработчик, которого мы взяли через месяц после окончания миграции: он сказал «не понимаю как это раньше работало». Вот именно.

10. Что бы я сделал иначе

  1. Включил strictNullChecks с первого дня. Мы ждали 80% конвертации. Ошибка. Если бы включили сразу, нашли бы prod-баги на месяц раньше, и каждый конвертируемый файл сразу писался бы с правильными null-паттернами.

  2. Типизировал тесты первыми, а не последними. Мы оставили тестовые файлы на самый конец. В итоге имели типобезопасный source code покрытый нетипизированными тестами — парадокс. Тесты сами по себе содержали type-ошибки которые маскировали проблемы.

  3. Запретил as без комментария с первого PR. Результат: 200+ необъяснённых type assertions в кодовой базе. Половину уже не помним зачем. Правильно:

    // MIGRATION: Prisma returns any here until we upgrade to v5
    const user = result as User;
    
  4. Сделал CLAUDE.md специфичным для миграции сразу. Первые 2 недели работали с общим CLAUDE.md. Когда добавили секцию ## Migration Rules с явным запретом any и требованием unknown — качество конвертаций заметно выросло. Claude начал задавать уточняющие вопросы вместо того чтобы молча ставить any.

11. Когда НЕ стоит мигрировать с AI

Это не серебряная пуля. Есть случаи когда Claude Code скорее навредит чем поможет.

Кодовая база без тестов с запутанными side effects. Claude не запускает код — он рассуждает. В коде с неочевидными глобальными эффектами он поставит «правильные» типы но изменит логику. Без тестов ты этого не заметишь.

Файлы больше 500 строк со сложной бизнес-логикой. Context window не справится. Конвертируй вручную или разбей файл сначала.

Динамические типы через runtime. eval, dynamic require, сложные Proxy-паттерны — Claude угадывает. В лучшем случае поставит unknown, в худшем — поставит конкретный тип который окажется неверным в 10% случаев.

Команда плохо знает TypeScript. Это звучит контринтуитивно — «как раз AI поможет». Не поможет. Migration PR-ы «будут выглядеть правильно» но таить ошибки, которые некому заметить. Сначала команде нужно понять TypeScript, потом делегировать механику Claude.

Правило большого пальца: если ты не можешь проверить что Claude сделал правильно — не давай ему это делать.

12. Чек-лист для вашей команды

Всё что выше — в одном списке. Скопируй в свой Notion или Confluence.

Подготовка

  1. Настроить tsconfig.json: allowJs: true, strict: false, tsc --noEmit в CI

  2. Создать src/types/ с boundary types: API-ответы, DB-модели, shared interfaces

  3. Написать секцию ## Migration Rules в CLAUDE.md с явным запретом any

Процесс

  1. Начинать с leaf modules: utils/, helpers/, validators/

  2. Покрыть модуль snapshot-тестами ДО отдачи Claude

  3. Один модуль = один PR, никогда не смешивать с фичами

  4. Файлы > 300 строк конвертировать частями по 150–200 строк

  5. Еженедельный scorecard (% TS-файлов, количество strict-ошибок)

CI/CD

  1. Guard против новых .js файлов в PR

  2. Включать strict-флаги поочерёдно: strictNullChecksnoImplicitAnystrict

  3. Запретить any через ESLint после 100% конвертации

Review

  1. Каждый migration PR — живой code review на предмет изменения логики

  2. Запрет as без комментария // MIGRATION: reason с первого дня

  3. Тестовые файлы конвертировать последними, отдельным PR

Миграция заняла 6 недель вместо оценочных 6 месяцев ручной работы. Это не значит что Claude Code делает всё сам — он делает механическую работу точно и быстро, пока ты контролируешь архитектурные решения и проверяешь логику.

Главный инсайт который я не ожидал: миграция с AI — это дисциплина, а не инструмент. Правила в CLAUDE.md, batch-стратегия, обязательные тесты — без этого Claude будет просто быстрым способом создать технический долг в TypeScript.

Если у вас есть вопросы по конкретным паттернам или вы сами в процессе миграции — пишите в комментарии, отвечу.

Слежу за темой AI-инструментов в продакшене в Twitter @Alex_Rogov_js и в Telegram-канале AI-усиленный разработчик — там короткие разборы без воды.*

Комментарии (1)


  1. Dreams_and_magic
    27.05.2026 20:31

    Спасибо за статью, пока читал, аж пульс повысился))