DEV Community

Boris Grinshpun
Boris Grinshpun

Posted on

Flow-to-TypeScript AI Migration

Everyone thinks migrating a codebase with AI means pointing a model at your files and saying "convert these."

That's not a plan. That's a wish.

Migrating a 10-year-old product isn't a refactor; it's an excavation. We had ~1,100 files deeply entrenched in Flow (last real release: 2019) that we had to safely move to TypeScript without breaking the app.did

Before writing a single prompt, we sat with why the "just let the AI do it" approach fails:

  • Context is bounded: Feed a model a slice of 1,100 interdependent files, and it's blind to the rest.
  • No memory: A type in File A is invisible when looking at File B, leading to broken imports and hallucinations.
  • No ground truth: AI feels confident even when wrong. Without compile/test checks, you stack silent breakages.

The model was never the hard part. The system around it was. Over a hackathon, a team of 3 built an AI migration engine. Here is how we did it:

1. We migrated the build first (Webpack → Vite)

You can't convert file-by-file if the app only builds when it's all done. Moving to Vite + esbuild allowed legacy .js and new .ts to coexist, keeping the app green.

2. We mapped the graph, then split it into groups

None of this works if you throw random files at the model. So before any conversion, two static-analysis passes ran over the codebase:

  • Dependency graph: the full import graph — who imports whom, plus every circular dependency (the traps that break naive migration).
  • Type-ownership map: for every exported type, which file owns it and which files consume it.

Then we split the ~1,100 files into small atomic groups: a type's owner file plus every file that consumes that type migrate together — so no import ever straddles the Flow/TS boundary mid-migration.

Finally, we sorted those ~720 groups leaf-first — dependencies always migrated before the things that depended on them. The output is a single ordered plan the loop just walks down.

One rule the model forced on us: you can't one-shot a monolith. Files like actions.js (4,300+ lines) were too big to convert reliably in a single pass, so we split them into ~14 focused domain modules first, then fed those to the loop.

3. We built an AI loop with a referee

Instead of a single prompt, we built a pipeline: Planner → Implementer (Claude) → Reviewer (tsc + tests).

   Planner  ──►  Implementer  ──►  Reviewer  ──► ✅ commit
  (next group    (Claude writes    (tsc + tests)
   from plan)      the TS)              │
      ▲                                 │ errors?
      │                                 ▼
      └────────  retry (feed errors back in)  ◄──┘
                          │
                   stuck after 3 tries?
                          ▼
                  🧠 Architect (bigger model)
             → retry smarter / reorder / flag a human
Enter fullscreen mode Exit fullscreen mode

The Reviewer only commits on green. Failures feed exact errors back for a retry. If it gets stuck after 3 tries, the system escalates or flags a human.

We ran two models on purpose: a fast, cheap one (Claude Sonnet) does the routine ~95% of conversions; a heavier one (Claude Opus) only steps in as the Architect on stuck cases — to reason about why it's stuck and decide the next move (retry smarter, reorder, or escalate). And not everything even needs the loop: the interdependent source files ran through the full pipeline, but hundreds of mechanical test-file conversions were handled by a plain batch script — no orchestrator overhead.

And we constrained the model, hard. It wasn't handed a blank check — every prompt forbade any and forbade as unknown as X casts that silence errors instead of fixing them; it had to import real library types rather than invent shapes, and trace prop types from how a component is actually used by its parents. Most importantly: the model only ever edits files — no shell, no git. All verification lives outside it, in the Reviewer.

The stack

  • Orchestration: LangGraph JS — the Planner, Implementer, Reviewer, and Architect are nodes in a state machine, not one big prompt.
  • Engine: Claude Code CLI, driven by two models — Claude Sonnet (routine) + Claude Opus (stuck cases).
  • The retry pattern: a "Ralph Loop" — keep re-running the Implementer, feeding the exact tsc/test errors back in, until it's green or declared stuck.
  • Static analysis: madge (dependency graph), @babel/parser (type-ownership map), Kahn's algorithm (leaf-first topological sort).
  • Verification gate: tsc --noEmit + Jest.
  • Build & types: Vite 6 + esbuild; openapi-typescript generating API types from Swagger/OpenAPI specs.
  • State: a JSON checkpoint on disk with file-based locking — which is what makes it killable, resumable, and parallelizable.

Runtime: Node + TypeScript throughout.

If you're pointing AI at a big migration, here are your takeaways:

  • Fix the build first: You need mixed-state compilation.
  • Feed it a graph: Give it self-contained units with resolved dependencies.
  • Decompose the giants first: AI can't reliably one-shot a 4,000-line file. Split monoliths into focused modules before you migrate them.
  • Order is correctness: Leaf-first prevents downstream breaks.
  • Fix structure, not symptoms: A circular-dependency chain caused 318 test failures. We broke it with layer separation — pulling pure data out of component-coupled code — not a lazy require() hack. One structural fix cleared hundreds of errors.
  • Supply ground truth: Wire your tests as a hard commit gate.
  • Errors are prompts: Feed failures back in to create a self-correcting loop.
  • Escalate on purpose: Cheap models for 95%, expensive for 5%, humans for the rest.
  • Match the tool to the task: Full agent loop for the hard, interdependent code; a plain batch script for mechanical bulk. Don't pay for judgment you don't need.
  • Guardrail the agent: Forbid any, forbid error-silencing casts, force real library types. Give it edit-only power — no shell, no git — and keep verification external.
  • Make it resumable: Checkpoint state to disk.
  • A migration is a free audit: Generating types from your API specs reveals every place your contracts have drifted from reality — and every service that has no contract at all.

The result: ~1,100 files migrated, Flow gone, the app green throughout. And it wasn't a hackathon toy that died on Monday — the migration shipped to production the following sprint.

The meta-lesson: AI doesn't replace engineering judgment — it scales it.

What's the migration you keep putting off because it's "too big to do by hand"?

#AI #TypeScript #SoftwareEngineering #DeveloperTools #Claude

Top comments (0)