← Back to Blog
TypeScript2026-05-13Ā·86 min read

Claude Code for TypeScript Migrations: How I Converted a 200,000-Line JavaScript Codebase Without Stopping Shipping

By Nex Tools

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.json with allowJs and checkJs enabled
  • 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 unknown fallback 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 noImplicitAny lint rule and block merges containing exported any

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

  1. Initialize the baseline: Create tsconfig.migration.json with allowJs: true and checkJs: true. Run npx tsc --noEmit to verify the compiler accepts mixed files without errors.
  2. Map the dependency graph: Execute the dependency scanner against your src/ directory. Export the topological queue to migration-queue.json. Identify leaf modules for initial migration.
  3. Configure AI inference: Set up the type inference engine to read source files, extract export signatures, and query caller usage sites. Configure fallback to unknown for ambiguous patterns.
  4. 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.
  5. Begin the strictness ramp: Start with permissive compiler flags. After 30% of files are typed, enable noImplicitAny. After 60%, enable strictNullChecks. Progress to full strict mode only when the codebase is fully migrated.