Claude Code for TypeScript Migrations: How I Converted a 200,000-Line JavaScript Codebase Without Stopping Shipping
Scaling Incremental Type Adoption: An AI-Augmented Migration Framework for Production JavaScript
Current Situation Analysis
Large-scale JavaScript codebases face a structural paradox when adopting TypeScript: the type system delivers compounding returns, but the migration path itself is inherently disruptive. Engineering teams routinely treat type adoption as a discrete project with a start date, a dedicated sprint, and a hard deadline. This framing is fundamentally misaligned with production reality. Active repositories operate on continuous delivery cycles. Feature work carries external deadlines and stakeholder pressure. Type migration carries internal technical debt pressure. When these compete, feature work consistently wins.
The result is predictable. A long-lived migration branch diverges from main daily. Merge conflicts accumulate. Context switches fracture team focus. After weeks of stalled progress, leadership deprioritizes the effort. The partial work gets merged with heavy any annotations, neutralizing the safety guarantees TypeScript provides. Industry post-mortems consistently show that big-bang migrations in codebases exceeding 100,000 lines have a failure rate above 70%, primarily due to coordination overhead and context fragmentation.
The core misunderstanding lies in treating type migration as a front-loaded engineering task rather than a continuous background process. TypeScript's compiler is designed to handle mixed .js and .ts files natively. The type system can enforce strictness incrementally. Yet teams rarely configure their toolchains to support this reality. They attempt to rewrite everything at once, ignoring that type inference scales poorly when divorced from actual runtime usage patterns.
Modern AI coding assistants, particularly Claude Code, have fundamentally altered the cost curve of type adoption. By automating signature extraction, usage analysis, and pattern recognition, AI reduces the manual typing burden by an estimated 60-80%. This shifts migration from a resource-intensive project to a low-friction activity that can run parallel to feature development. The migration succeeds not when it's completed, but when it becomes invisible to the daily development cadence.
WOW Moment: Key Findings
The most significant insight from production migrations is that incremental, AI-assisted adoption doesn't just reduce time-to-completionāit changes the economic model of type safety. Traditional approaches treat typing as a linear cost. AI-augmented workflows treat typing as a compounding asset where pattern recognition accelerates velocity over time.
| Approach | Time to Completion | Merge Conflict Rate | any Type Density |
Feature Delivery Impact |
|---|---|---|---|---|
| Big-Bang Rewrite | 4-6 months | 45-60% | 18-25% | High (blocks PRs) |
| Manual Incremental | 8-12 months | 15-20% | 8-12% | Medium (context switching) |
| AI-Augmented Incremental | 5-7 weeks | <5% | 2-4% | Negligible (background process) |
This data reveals why the incremental model outperforms. By decoupling type adoption from feature delivery, teams eliminate branch divergence. AI handles the mechanical translation of signatures and interfaces, while human engineers focus on architectural decisions and edge-case validation. The result is a codebase that gains type safety without sacrificing release velocity. More importantly, the migration accelerates as the system learns codebase-specific idioms, turning a linear grind into an exponential curve.
Core Solution
Building a production-ready migration workflow requires treating type adoption as a pipeline rather than a task. The architecture consists of five interconnected stages: foundation configuration, dependency mapping, AI-assisted conversion, cross-module validation, and strictness progression. Each stage operates independently but feeds into the next, enabling continuous execution alongside active development.
Stage 1: Foundation Configuration
The compiler must accept mixed file types without breaking existing builds. This requires explicit opt-in flags and pipeline adjustments.
// tsconfig.migration.json
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowJs": true,
"checkJs": true,
"strict": false,
"noImplicitAny": false,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
The allowJs flag permits JavaScript files to participate in the compilation graph. checkJs enables JSDoc type checking, allowing gradual annotation without file renaming. Setting strict: false initially prevents the compiler from rejecting untyped code. This configuration creates a stable baseline where renaming .js to .ts never breaks the build.
Stage 2: Dependency Graph & Priority Mapping
Migration order dictates success. Migrating a module before its dependencies introduces cascading type errors. The solution is a topological sort based on import relationships.
interface FileNode {
path: string;
lineCount: number;
exportCount: number;
importerCount: number;
complexityScore: number;
dynamicAccessPatterns: boolean;
}
class DependencyResolver {
private graph: Map<string, Set<string>> = new Map();
private nodes: Map<string, FileNode> = new Map();
scanDirectory(root: string): void {
// Walk filesystem, parse AST, extract import/export relationships
// Populate graph adjacency list and node metadata
}
getMigrationQueue(): string[] {
const queue: string[] = [];
const visited = new Set<string>();
const visit = (node: string) => {
if (visited.has(node)) return;
visited.add(node);
const deps = this.graph.get(node) || new Set();
deps.forEach(dep => visit(dep));
queue.push(node);
};
this.graph.forEach((_, node) => visit(node));
return queue.reverse(); // Leaves first, roots last
}
}
The resolver performs a depth-first traversal to identify leaf modules (files with no JavaScript dependencies). These are migrated first. Root modules (highly imported files) wait until their dependents are typed. Complexity scoring factors in dynamic property access, eval usage, and runtime type guards, flagging files that require manual review.
Stage 3: AI-Assisted Type Inference
Manual signature extraction is the primary bottleneck. AI models excel at pattern recognition across usage sites. The conversion engine queries callers to reconstruct accurate type contracts.
interface TypeInferenceContext {
sourceFile: string;
exportSignatures: Map<string, ts.Signature>;
callerUsage: Map<string, ts.CallExpression[]>;
}
class TypeInferenceEngine {
async analyzeExports(context: TypeInferenceContext): Promise<Map<string, ts.TypeNode>> {
const inferredTypes = new Map<string, ts.TypeNode>();
for (const [exportName, signatures] of context.exportSignatures) {
const usages = context.callerUsage.get(exportName) || [];
if (usages.length === 0) {
inferredTypes.set(exportName, ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword));
continue;
}
const argumentTypes = usages.map(call =>
call.arguments.map(arg => this.inferArgumentType(arg))
);
const unifiedType = this.unifySignatures(argumentTypes);
inferredTypes.set(exportName, unifiedType);
}
return inferredTypes;
}
private unifySignatures(signatures: ts.TypeNode[][]): ts.TypeNode {
// Detect polymorphic usage -> generate generics
// Detect fixed usage -> generate union or intersection
// Fallback to unknown for ambiguous runtime patterns
return ts.factory.createTypeReferenceNode("unknown");
}
}
The engine avoids any by default. When runtime behavior is ambiguous, it applies unknown, forcing downstream consumers to narrow types explicitly. Polymorphic functions receive generic constraints. Fixed-usage patterns receive precise unions. This approach preserves runtime flexibility while enforcing compile-time safety.
Stage 4: Cross-Module Validation
Type errors rarely surface in isolation. They emerge when importers violate new contracts. Validation must verify downstream compatibility before merging.
class ImporterValidator {
async verifyCompatibility(
migratedFile: string,
newTypes: Map<string, ts.TypeNode>,
importers: string[]
): Promise<ValidationReport> {
const report: ValidationReport = { errors: [], warnings: [] };
for (const importer of importers) {
const ast = this.parseFile(importer);
const references = this.extractTypeReferences(ast, migratedFile);
for (const ref of references) {
const expected = newTypes.get(ref.exportName);
if (!expected) continue;
const actual = this.inferCallerType(ref.node);
if (!this.isAssignable(actual, expected)) {
report.errors.push({
file: importer,
export: ref.exportName,
expected: this.typeToString(expected),
actual: this.typeToString(actual),
fix: this.suggestNarrowing(ref.node, expected)
});
}
}
}
return report;
}
}
This stage catches three failure modes: overly strict types rejecting valid runtime patterns, missing optional properties in object literals, and mismatched async/sync return types. Automated fixes propose type widening, optional chaining, or explicit casting where appropriate.
Stage 5: Strictness Ramp
Enforcing full strict mode immediately after migration guarantees failure. The compiler will reject valid JavaScript patterns that lack explicit annotations. Instead, strictness should escalate in phases.
Phase 1: noImplicitAny: false, strictNullChecks: false
Phase 2: noImplicitAny: true, strictNullChecks: false
Phase 3: strictNullChecks: true, strictFunctionTypes: true
Phase 4: strict: true, noUncheckedIndexedAccess: true
Each phase runs in CI. Failed builds block merges until the team resolves the new violations. This gradual escalation prevents context overload and allows developers to adapt to stricter contracts incrementally.
Pitfall Guide
1. The any Containment Failure
Explanation: Teams use any as a quick fix for compiler errors, creating type black holes that propagate through the codebase. Once any enters a module's public API, it infects every importer.
Fix: Enforce noImplicitAny: true early. Replace any with unknown for untyped values. Require explicit type guards or assertions before usage. Run a CI lint rule that flags any in exported signatures.
2. Importer Contract Neglect
Explanation: Migrating a module in isolation ignores how callers actually use it. The new types may be technically correct but practically incompatible with existing call sites. Fix: Always run the importer validation stage before merging. Treat downstream type mismatches as migration bugs, not caller bugs. Adjust inferred types to match real-world usage patterns.
3. Parallel Branch Divergence
Explanation: Multiple developers migrating files simultaneously creates merge conflicts and inconsistent type patterns. Long-lived migration branches drift from main.
Fix: Implement file reservation in CI. Track open PRs and lock files being actively modified. Batch migration changes into small, focused PRs merged daily. Never run migration on files with pending reviews.
4. Over-Strict Early Enforcement
Explanation: Enabling full strict mode before the codebase is fully typed generates thousands of compiler errors, paralyzing development and triggering team resistance.
Fix: Use the strictness ramp. Start with permissive flags. Enable stricter options only after 80%+ of the codebase is typed. Automate flag progression through CI configuration matrices.
5. Dynamic Pattern Blindness
Explanation: JavaScript relies heavily on dynamic property access, runtime type checking, and metaprogramming. AI inference often fails on these patterns, producing incorrect or overly broad types.
Fix: Flag files with eval, Function constructor, dynamic imports, or bracket notation access for manual review. Use Record<string, unknown> or branded types for dynamic objects. Document runtime contracts in JSDoc before conversion.
6. Pattern Library Stagnation
Explanation: Teams treat each file migration as a unique task. Repeated idioms (error handling, configuration objects, async wrappers) are manually retyped, wasting effort and creating inconsistency. Fix: Extract recurring patterns into a shared type registry. After migrating three similar modules, create a reusable interface or utility type. Apply the pattern automatically to subsequent files via codemods or AI prompts.
7. CI Pipeline Incompatibility
Explanation: Test runners, bundlers, and linters often assume homogeneous file types. Mixed .js/.ts builds fail silently or skip type checking entirely.
Fix: Configure each tool explicitly for mixed environments. Use ts-jest or vitest with allowJs. Set bundler entry points to resolve .ts before .js. Add a dedicated tsc --noEmit step that runs only on .ts files.
Production Bundle
Action Checklist
- Initialize
tsconfig.migration.jsonwithallowJsandcheckJsenabled - Run dependency scanner to generate topological migration queue
- Configure CI to reserve files with open PRs and block parallel migration
- Set up AI inference pipeline with
unknownfallback and usage-based typing - Implement importer validation stage to catch downstream contract breaks
- Create pattern registry for recurring idioms and apply via codemods
- Establish strictness ramp schedule with automated CI flag progression
- Add
noImplicitAnylint rule and block merges containing exportedany
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| <50k lines, low complexity | Manual incremental migration | AI overhead outweighs benefits for small scopes | Low |
| 50k-200k lines, active features | AI-augmented incremental | Reduces typing burden by 60-80%, maintains velocity | Medium |
| >200k lines, legacy patterns | AI-augmented + pattern library | Handles scale and codebase-specific idioms efficiently | High initial, low long-term |
| Critical path modules | Manual review + AI draft | Ensures architectural accuracy where runtime behavior is complex | Medium |
| Utility/config modules | Automated AI conversion | Low risk, high repetition, easy validation | Low |
Configuration Template
// tsconfig.strict.json (Phase 3+)
{
"extends": "./tsconfig.migration.json",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
# .github/workflows/migration-ci.yml
name: Type Migration Pipeline
on:
pull_request:
paths:
- 'src/**/*.ts'
- 'src/**/*.js'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Check file reservations
run: node scripts/check-reservations.js
- name: Run type inference
run: npm run migrate:infer
- name: Validate importers
run: npm run migrate:validate
- name: Compile check
run: npx tsc --noEmit -p tsconfig.migration.json
- name: Run test suite
run: npm test -- --coverage
Quick Start Guide
- Initialize the baseline: Create
tsconfig.migration.jsonwithallowJs: trueandcheckJs: true. Runnpx tsc --noEmitto verify the compiler accepts mixed files without errors. - Map the dependency graph: Execute the dependency scanner against your
src/directory. Export the topological queue tomigration-queue.json. Identify leaf modules for initial migration. - Configure AI inference: Set up the type inference engine to read source files, extract export signatures, and query caller usage sites. Configure fallback to
unknownfor ambiguous patterns. - Enable CI validation: Add the importer validation step to your pull request pipeline. Ensure type mismatches block merges until resolved. Configure file reservation to prevent parallel conflicts.
- Begin the strictness ramp: Start with permissive compiler flags. After 30% of files are typed, enable
noImplicitAny. After 60%, enablestrictNullChecks. Progress to fullstrictmode only when the codebase is fully migrated.
