Я перевёл 200K строк JS на TS с Claude Code. Что прошло, что сломалось

от автора

За 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.jsfunction 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 RulesConvert 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.tsinterface 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.shJS_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 onlyAfter conversion, list any places where you had to makea judgment call about the type.

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

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

I need to type the return value of getUserData() insrc/services/user.service.js.Look at every place where getUserData() is called acrossthe codebase. For each call site, show me:1. The file and line2. How the result is used3. What TypeScript type this impliesThen suggest the return type.

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

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

In the last conversion of format-price.ts you changedbehavior on line 23: the original checked !value(catches null, undefined, 0, ''), your version checksvalue === 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 itexists in the original JS.

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

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

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

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

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

// Оригинал: src/utils/concat-name.jsfunction 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.ymlname: 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 v5const 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-усиленный разработчик — там короткие разборы без воды.*

ссылка на оригинал статьи https://habr.com/ru/articles/1040326/