За 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 изменил поведение. Разбирайся почему.
Три правила:
-
Если у модуля нет тестов — конвертируй вручную. Не отдавай Claude то, что не можешь проверить. Исключение: чистые utility-функции с очевидной логикой.
-
Тесты конвертируй последними, отдельным PR. Мы сначала хотели конвертировать всё разом — исходники и тесты. Не делайте так. Нетипизированные тесты проверяют типизированный код — это нормально. Зато если тест упадёт, ты точно знаешь что сломал Claude, а не то что тест написан криво.
-
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. Что бы я сделал иначе
-
Включил
strictNullChecksс первого дня. Мы ждали 80% конвертации. Ошибка. Если бы включили сразу, нашли бы prod-баги на месяц раньше, и каждый конвертируемый файл сразу писался бы с правильными null-паттернами. -
Типизировал тесты первыми, а не последними. Мы оставили тестовые файлы на самый конец. В итоге имели типобезопасный source code покрытый нетипизированными тестами — парадокс. Тесты сами по себе содержали type-ошибки которые маскировали проблемы.
-
Запретил
asбез комментария с первого PR. Результат: 200+ необъяснённых type assertions в кодовой базе. Половину уже не помним зачем. Правильно:// MIGRATION: Prisma returns any here until we upgrade to v5const user = result as User; -
Сделал 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.
Подготовка
-
Настроить
tsconfig.json:allowJs: true,strict: false,tsc --noEmitв CI -
Создать
src/types/с boundary types: API-ответы, DB-модели, shared interfaces -
Написать секцию
## Migration RulesвCLAUDE.mdс явным запретомany
Процесс
-
Начинать с leaf modules:
utils/,helpers/,validators/ -
Покрыть модуль snapshot-тестами ДО отдачи Claude
-
Один модуль = один PR, никогда не смешивать с фичами
-
Файлы > 300 строк конвертировать частями по 150–200 строк
-
Еженедельный scorecard (% TS-файлов, количество strict-ошибок)
CI/CD
-
Guard против новых
.jsфайлов в PR -
Включать strict-флаги поочерёдно:
strictNullChecks→noImplicitAny→strict -
Запретить
anyчерез ESLint после 100% конвертации
Review
-
Каждый migration PR — живой code review на предмет изменения логики
-
Запрет
asбез комментария// MIGRATION: reasonс первого дня -
Тестовые файлы конвертировать последними, отдельным PR
Миграция заняла 6 недель вместо оценочных 6 месяцев ручной работы. Это не значит что Claude Code делает всё сам — он делает механическую работу точно и быстро, пока ты контролируешь архитектурные решения и проверяешь логику.
Главный инсайт который я не ожидал: миграция с AI — это дисциплина, а не инструмент. Правила в CLAUDE.md, batch-стратегия, обязательные тесты — без этого Claude будет просто быстрым способом создать технический долг в TypeScript.
Если у вас есть вопросы по конкретным паттернам или вы сами в процессе миграции — пишите в комментарии, отвечу.
Слежу за темой AI-инструментов в продакшене в Twitter @Alex_Rogov_js и в Telegram-канале AI-усиленный разработчик — там короткие разборы без воды.*
ссылка на оригинал статьи https://habr.com/ru/articles/1040326/