Я перевёл 200K строк JS на TS с Claude Code. Что прошло, что сломалось
- пятница, 29 мая 2026 г. в 00:00:17
За 6 недель Claude Code преобразовал 200K строк JS в strict TypeScript. Не переименование файлов, а настоящая типизация: интерфейсы, строгие null-чеки, перехваченные баги в проде. Тут разбор реального кейса с цифрами, ошибками агента и главным вопросом: стоит ли вам это повторять?
Кодовой базе было 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 месяцев работы только над типами, если заморозить фичи. Нереально.
Первый инструмент который приходит в голову — 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.
Это 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-модели — первые, бизнес-логика — последняя.
«Отдай 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 что-то не то сделал.
Это самый ценный раздел. Промпты которые работают в продакшене, не в демо.
Промпт #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.
Честная часть. Без этого статья была бы рекламой.
Случай 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 с вопросом «изменилась ли логика?» Автоматика этого не поймает, потому что тесты проверяют поведение, а не намерение.
Без тестов миграция с 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 недели миграции.
Миграция без 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 — это маленькая деталь, которая сильно работает на мотивацию.
Прошло 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 в сумме заказа. Третий баг нашёл разработчик, которого мы взяли через месяц после окончания миграции: он сказал «не понимаю как это раньше работало». Вот именно.
Включил strictNullChecks с первого дня. Мы ждали 80% конвертации. Ошибка. Если бы включили сразу, нашли бы prod-баги на месяц раньше, и каждый конвертируемый файл сразу писался бы с правильными null-паттернами.
Типизировал тесты первыми, а не последними. Мы оставили тестовые файлы на самый конец. В итоге имели типобезопасный source code покрытый нетипизированными тестами — парадокс. Тесты сами по себе содержали type-ошибки которые маскировали проблемы.
Запретил as без комментария с первого PR. Результат: 200+ необъяснённых type assertions в кодовой базе. Половину уже не помним зачем. Правильно:
// MIGRATION: Prisma returns any here until we upgrade to v5 const user = result as User;
Сделал CLAUDE.md специфичным для миграции сразу. Первые 2 недели работали с общим CLAUDE.md. Когда добавили секцию ## Migration Rules с явным запретом any и требованием unknown — качество конвертаций заметно выросло. Claude начал задавать уточняющие вопросы вместо того чтобы молча ставить any.
Это не серебряная пуля. Есть случаи когда Claude Code скорее навредит чем поможет.
Кодовая база без тестов с запутанными side effects. Claude не запускает код — он рассуждает. В коде с неочевидными глобальными эффектами он поставит «правильные» типы но изменит логику. Без тестов ты этого не заметишь.
Файлы больше 500 строк со сложной бизнес-логикой. Context window не справится. Конвертируй вручную или разбей файл сначала.
Динамические типы через runtime. eval, dynamic require, сложные Proxy-паттерны — Claude угадывает. В лучшем случае поставит unknown, в худшем — поставит конкретный тип который окажется неверным в 10% случаев.
Команда плохо знает TypeScript. Это звучит контринтуитивно — «как раз AI поможет». Не поможет. Migration PR-ы «будут выглядеть правильно» но таить ошибки, которые некому заметить. Сначала команде нужно понять TypeScript, потом делегировать механику Claude.
Правило большого пальца: если ты не можешь проверить что Claude сделал правильно — не давай ему это делать.
Всё что выше — в одном списке. Скопируй в свой 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-усиленный разработчик — там короткие разборы без воды.*