Automating JavaScript to TypeScript Migration with AST and LLMs: A Safe Pipeline for Large Codebases

AI tutorial - IT technology blog
AI tutorial - IT technology blog

Real-World Problem: The 250,000-Line Migration Bottleneck

Six months ago, our team hit a wall. We needed to migrate a legacy Node.js/Express backend—250,000 lines of JavaScript spread across roughly 1,200 files—to strict TypeScript. Five years of rapid feature development had left a trail of untyped ORM models, implicit globals, and ad-hoc object mutations. A purely manual migration would have tied up six engineers for nearly nine months. We didn’t have that luxury.

We started with off-the-shelf codemods like ts-migrate and airbnb-ts-migrate. They handled the file renames and basic boilerplate quickly. However, they also peppered over 14,000 lines with any annotations and // @ts-expect-error suppressions. The codebase compiled, but we gained almost zero compile-time safety.

Next, we tried piping entire source files into LLMs. The generated code looked clean at first glance, but subtle regressions broke staging immediately. The models hallucinated uninstalled npm packages, duplicated internal utilities, and occasionally swapped synchronous loops for async patterns that silently broke execution order. After two weeks of debugging flaky test suites, we scrapped the full-file approach.

Root Cause Analysis: Syntactic Rigidity vs. Contextual Hallucination

Automated migration fails when you treat structural transformation and semantic type inference as the same job. They demand completely different tooling:

  • AST Codemods provide deterministic precision. Parsers convert code into an Abstract Syntax Tree, making it easy to rename declarations, update module syntax, or insert nodes without touching runtime logic. But ASTs can’t infer developer intent. An AST parser cannot inspect an unannotated req.body and deduce that it should be typed as a CreateUserDTO with an optional billingAddress.
  • LLMs excel at contextual reasoning. They can scan database queries, validation schemas, and callers across adjacent files to generate accurate domain models. But LLMs are probabilistic engines. Handing a model a 500-line file gives it too many opportunities to drop comments, reorder statements, or subtly alter logic.

ASTs provide structural safety without semantic understanding. LLMs provide semantic understanding without structural guarantees. Combining them solves both problems.

Solutions Compared: Benchmarking Migration Strategies

We benchmarked four distinct approaches across a representative sample of 1,200 files in our repository:

Strategy Throughput Strict Type Coverage Runtime Safety Manual Effort
1. Manual Migration ~3 files / engineer / day High (~95%) High Extremely High (6 engineers, 9 months)
2. Pure AST / Codemods Instant (< 2 minutes total) Very Low (< 15% without any) High (Preserves syntax) High (Requires extensive manual typing)
3. Full-File LLM Prompts ~20 files / hour Moderate (~70%) Low (Frequent logic drift) High (Painstaking line-by-line QA)
4. AST + LLM Hybrid Pipeline ~120 files / hour High (~94%) Highest (Compiler-verified) Minimal (Reviewing pull requests)

The Solution: A Three-Stage Dual-Engine Pipeline

Our production pipeline separates structural manipulation from type synthesis in three distinct phases: AST Extraction → Targeted LLM Type Synthesis → AST Injection & Compiler Validation.

Phase 1: Deterministic AST Extraction

First, an AST script parses each JavaScript file, converts CommonJS require calls to ES module import statements, and maps every untyped function declaration. It extracts the raw code of just the function signature and its immediate body without modifying anything else.

Here is a Node.js script using @babel/parser and @babel/traverse to extract untyped target functions:

const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;

function extractUntypedSignatures(filePath) {
  const code = fs.readFileSync(filePath, 'utf-8');
  const ast = parser.parse(code, {
    sourceType: 'module',
    plugins: ['jsx']
  });

  const targets = [];

  traverse(ast, {
    FunctionDeclaration(path) {
      // Check if parameters lack type annotations
      const hasUntypedParams = path.node.params.some(p => !p.typeAnnotation);
      if (hasUntypedParams) {
        targets.push({
          functionName: path.node.id ? path.node.id.name : 'anonymous',
          start: path.node.start,
          end: path.node.end,
          params: path.node.params.map(p => p.name || 'destructured'),
          rawSnippet: code.slice(path.node.start, path.node.end)
        });
      }
    }
  });

  return targets;
}

const targets = extractUntypedSignatures('./src/services/userService.js');
console.log(`Found ${targets.length} functions requiring types.`);

Phase 2: Targeted LLM Type Synthesis

Instead of passing 500 lines of mixed code to an LLM, we send only the extracted function snippet alongside schema context (such as database definitions or JSDoc comments). We force the LLM to return strictly typed interface definitions and signature annotations in JSON format.

By constraining the LLM to output only type definitions, the model cannot corrupt the underlying runtime logic.

import json
import requests

def synthesize_types(function_snippet: str, schema_context: str) -> dict:
    prompt = f"""
    Analyze this JavaScript function and return TypeScript types.
    DO NOT rewrite or alter the function logic.
    
    Schema Context:
    {schema_context}
    
    Function Snippet:
    {function_snippet}
    
    Return a strict JSON object with two keys:
    1. "interfaces": array of TypeScript interface/type strings.
    2. "params": array of objects with "name" and "type" strings.
    3. "returnType": string representing the return type.
    """
    
    payload = {
        "model": "gemini-2.5-flash",
        "messages": [{"role": "user", "content": prompt}],
        "response_format": {"type": "json_object"}
    }
    
    response = requests.post(
        "https://api.example.com/v1/chat/completions",
        headers={"Authorization": "Bearer YOUR_API_KEY"},
        json=payload,
        timeout=30
    )
    return json.loads(response.json()["choices"][0]["message"]["content"])

Phase 3: AST Injection & Compiler Validation Loop

Once the LLM returns the type declarations, our AST engine injects the new interfaces at the top of the file and attaches the inferred type annotations to the function parameters. The original function body remains untouched.

We then trigger the TypeScript compiler to validate the transformed file against the project configuration:

# Validate types across the repository without writing emitted files
npx tsc --noEmit --project tsconfig.json

If tsc detects an error (such as a missing optional property flag), the pipeline captures the compiler diagnostic and feeds it back to the LLM for a self-correction pass. In our migration, 82% of initial type errors were automatically resolved on the first retry.

Results: 250,000 Lines Migrated in 18 Days

Deploying this hybrid pipeline across our core repositories delivered clear results:

  • Zero runtime regressions: All existing unit and integration tests passed on the first run because AST safeguards prevented logic drift.
  • 94% strict type coverage: Only 6% of dynamic edge cases—primarily dynamic EventEmitter payloads—required manual developer intervention.
  • 10x faster delivery: A project originally scoped for nine months was completed in 18 working days with just two engineers handling PR reviews.

When migrating large codebases, avoid relying exclusively on brittle regex scripts or unconstrained LLM rewrites. Let ASTs control the structure, and let LLMs supply the type intelligence.

Share: