DEV Community

yureki_lab
yureki_lab

Posted on

How I Let an AI Agent Migrate 800 JS Files to TypeScript in 3 Weeks

TL;DR

I had an 800-file JavaScript codebase and no appetite for a six-month TypeScript migration. I handed the mechanical part to an AI coding agent, kept the ordering and the ratchet for myself, and finished in three weeks of background work. Here's the exact strategy — plus the one rule I added early that made the agent measurably worse.

The Problem

The codebase was a Node.js 22 service plus a React front end, roughly 800 .js and .jsx files, about 140k lines. No types, decent test coverage (~70% on the server, ~35% on the UI), and the usual eight years of accumulated cleverness.

We'd tried migrating twice before. Both attempts died the same way:

  1. Someone converts a leaf file to .ts.
  2. TypeScript demands types for everything that file imports.
  3. Those imports demand types for their imports.
  4. The PR is now 60 files and untouchable, and it rots in review for three weeks.

That's the real problem with a TS migration, and it isn't a typing problem — it's a graph traversal problem wearing a typing costume. Every file you touch drags in its dependencies. Do it in the wrong order and every change is enormous. Do it in the right order and every change is boring.

Boring is exactly what an AI agent is good at. So the split I landed on was:

  • Me: decide the order, define the quality ratchet, review the diffs.
  • The agent: do the 800 boring conversions, one batch at a time, and never make the ratchet go backwards.

How I Solved It

Step 1: Build the import graph, then migrate leaves first

Before letting the agent touch anything, I extracted the module dependency graph and topologically sorted it. Files with zero internal imports go first — pure utilities, constants, formatters. Nothing they depend on needs types yet, because they don't depend on anything.

// scripts/import-graph.mjs — deliberately dumb, ~40 lines, good enough
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';
import path from 'node:path';

const IMPORT_RE = /(?:from\s+|require\()\s*['"](\.[^'"]+)['"]/g;

const files = globSync('src/**/*.{js,jsx}');
const graph = new Map();

for (const file of files) {
  const src = readFileSync(file, 'utf8');
  const deps = [...src.matchAll(IMPORT_RE)]
    .map((m) => path.resolve(path.dirname(file), m[1]))
    .filter((p) => p.startsWith(path.resolve('src')));
  graph.set(path.resolve(file), new Set(deps));
}

// Kahn's algorithm, but we only care about the layer number.
function layers(graph) {
  const remaining = new Map(graph);
  const out = [];
  while (remaining.size) {
    const layer = [...remaining.keys()].filter((f) =>
      [...remaining.get(f)].every((d) => !remaining.has(d))
    );
    if (!layer.length) { out.push([...remaining.keys()]); break; } // cycle: dump it
    layer.forEach((f) => remaining.delete(f));
    out.push(layer);
  }
  return out;
}

console.log(JSON.stringify(layers(graph), null, 2));
Enter fullscreen mode Exit fullscreen mode

This spat out 19 layers. Layer 0 was 94 files. Layer 18 was 3 files (the app entry points, naturally). That JSON became the agent's work queue.

The cycle-dumping line matters more than it looks: I had 31 files in a mutual-import knot. I converted those 31 by hand, in one PR, before starting. Agents are bad at circular dependencies for the same reason humans are — there's no correct place to begin. Do the hard 4% yourself and the agent gets a clean 96%.

Step 2: One batch = one layer slice, capped at 15 files

Each agent run got a task like this:

Convert these 12 files from .js to .ts. Every module they import is already typed — read the .d.ts/.ts of each import before you write annotations. Do not modify any file outside the list. Do not change runtime behavior: no reordering, no "while I'm here" refactors, no dependency changes. When done, run npm run typecheck and npm test -- <related tests> and paste both outputs.

Three constraints did the heavy lifting:

  • "Every import is already typed" — that's the whole point of the topological order. The agent never has to invent a type for a dependency; it can go read the real one. This alone cut hallucinated interfaces to near zero.
  • "Do not change runtime behavior" — without this, agents treat a migration as an invitation to modernize. I got Promise.all rewrites, varconst sweeps, and an unrequested error-handling redesign in my first batch. All plausible! All impossible to review inside a 12-file type diff.
  • "Paste both outputs" — forces the agent to actually run the checks rather than assert success. (I still verify in CI; I just want to catch failures before review, not during.)

Fifteen files was the ceiling where I could still meaningfully review a diff in one sitting. Below ~8 files the overhead of a run dominated. That band is going to be codebase-specific, but having a ceiling is not optional.

Step 3: The strict-mode ratchet

This is the part I'd port to any migration, agent or not.

tsconfig.json starts permissive. Then a CI check makes the numbers monotonically non-increasing:

{
  "compilerOptions": {
    "strict": false,
    "noImplicitAny": false,
    "allowJs": true,
    "checkJs": false
  }
}
Enter fullscreen mode Exit fullscreen mode
#!/usr/bin/env bash
# scripts/ratchet.sh — fails CI if debt grows
set -euo pipefail

BASELINE_ANY=$(cat .ratchet/any-count)
BASELINE_JS=$(cat .ratchet/js-count)

CURRENT_ANY=$(grep -rEc ':\s*any\b|as any' src --include='*.ts' --include='*.tsx' | awk -F: '{s+=$2} END {print s+0}')
CURRENT_JS=$(find src -name '*.js' -o -name '*.jsx' | wc -l | tr -d ' ')

echo "any: $CURRENT_ANY (baseline $BASELINE_ANY) | js files: $CURRENT_JS (baseline $BASELINE_JS)"

[ "$CURRENT_ANY" -le "$BASELINE_ANY" ] || { echo "❌ 'any' count went up"; exit 1; }
[ "$CURRENT_JS"  -le "$BASELINE_JS"  ] || { echo "❌ new .js files added"; exit 1; }

# Ratchet down: today's numbers are tomorrow's ceiling.
echo "$CURRENT_ANY" > .ratchet/any-count
echo "$CURRENT_JS"  > .ratchet/js-count
Enter fullscreen mode Exit fullscreen mode

Two counters, twelve lines of bash, and the migration cannot go backwards — not from the agent, not from a teammate shipping a feature in parallel, not from me at 11pm. When js-count hit 0, I flipped strict: true and did one final cleanup pass.

The ratchet is what makes an incremental migration actually terminate. Without it, you're not migrating, you're bailing water.

Step 4: Review the types, skim the mechanics

Every diff has two kinds of change: mechanical (.js.ts, add : string) and semantic (this parameter is actually optional, this returns T | null).

I stopped reading mechanical changes entirely. git diff with a word-diff and a filter for annotation-only lines gets you 80% of the noise gone:

git diff --word-diff=porcelain HEAD~1 -- '*.ts' | grep -E '^\+' | grep -vE '^\+\s*(:|as)\s' | less
Enter fullscreen mode Exit fullscreen mode

What I did read closely, every time: every new interface/type declaration, and every ?, | null, and | undefined the agent introduced. Those encode a claim about runtime behavior. That's where the bugs live.

I caught exactly two real bugs across the whole migration. Both were the same shape — the agent typed a parameter as required when a code path passed undefined, and the tests didn't cover that path. Both surfaced in review of a ? that wasn't there.

graph LR
    A[Import graph] --> B[Topological layers]
    B --> C[Batch: max 15 files]
    C --> D[Agent converts]
    D --> E[typecheck + tests]
    E -->|fail| D
    E -->|pass| F[Human reviews types only]
    F --> G[Ratchet check in CI]
    G --> C
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

1. Order is the whole game; the agent is the easy part. I spent two days on the import graph and batching strategy, and about 40 minutes total writing agent prompts. The prompts barely changed after batch three. If a mechanical refactor feels hard to hand to an agent, the problem is almost always that you haven't found the ordering that makes each unit independent.

2. Banning any up front made the agent worse. My first ratchet forbade any outright. What I got wasn't better types — it was confident fiction: elaborate interfaces for third-party payloads the agent had never seen, as unknown as Config casts, and one memorable 40-line type describing a webhook body that turned out to be wrong in four fields. Allowing any and counting it turned an unfalsifiable quality bar into a number that goes down. any you can grep for beats a wrong type you can't.

3. "Don't refactor while you're here" needs to be an explicit rule, repeated per batch. Every model I tried treats an open file as an invitation to improve it. It's not malice, it's helpfulness — and it's the fastest way to make a reviewable diff unreviewable. Say it in the prompt every time, even when it feels redundant.

4. Do the cyclic 4% by hand. Circular imports have no correct starting point, so the agent picks one, invents types to break the cycle, and produces something that typechecks but encodes a lie about the architecture. Thirty-one files of manual work bought me 769 files of clean automation.

5. The ratchet outlived the migration. I expected to delete ratchet.sh when the migration finished. Instead it's still in CI, now counting as any and @ts-expect-error. A migration is temporary; the mechanism that stops backsliding is permanent. Ship the ratchet first, migrate second.

The numbers

Before After
.js / .jsx files 800 0
any occurrences 61
strict mode off on
Calendar time 3 weeks
Real bugs found in review 2
Production incidents from the migration 0

Sixty-one anys left. I'm fine with that — they're all at genuine boundaries (third-party SDKs, one gnarly legacy serializer), they're counted, and the number can only go down.

What's Next

Two things I'm working on now:

  • Applying the same shape to a test-coverage push. Same structure: a graph (this time of untested modules by call depth), a batch size, and a coverage ratchet that can't go backwards. The migration taught me the pattern generalizes to anything mechanical, bounded, and verifiable.
  • Making the agent propose the batches. Right now I generate the layers and hand them over. The obvious next step is letting it read the graph and pick its own next slice, with the batch-size ceiling as a hard constraint. The ratchet already makes that safe to try — worst case, CI rejects the batch.

Wrap-up

If you're staring down a migration you keep postponing, the takeaway isn't "use an AI agent." It's this: find the ordering that makes each step independent, then build the ratchet that stops backsliding. Do those two things and the work becomes boring — and boring work is exactly what you can delegate, to an agent or to anyone else.

Stack for the curious: Node.js 22.x, TypeScript 5.x, Claude Code as the agent, plain bash for the ratchet. No custom tooling beyond the 40-line graph script above.


💬 Have you tried handing a big mechanical refactor to an AI agent? I'd genuinely like to hear where it broke for you — the failure modes are more interesting than the successes. Drop a comment.

🔔 Follow me here on Dev.to — I write up this kind of build log weekly, mostly about getting AI coding agents to do real work on real codebases without wrecking them.

🚀 And if you want to try this yourself: grab Claude Code, start with your leaf modules, and write the ratchet before you convert a single file.

Top comments (0)