Skip to content

Legacy Code Modernization at Scale

Your codebase has 80,000 lines of JavaScript from 2018 — no TypeScript, callbacks instead of async/await, class components instead of hooks, and a test suite that covers 15% of the code. Management wants it modernized “without any production regressions.” The last team that tried a big-bang rewrite spent six months and shipped nothing. Codex lets you modernize incrementally: migrate file by file in parallel worktrees, validate with tests at every step, and use cloud tasks with multiple attempts to find the best migration strategy for the tricky modules.

  • A phased modernization strategy that keeps the application deployable at every step
  • Prompts for common migrations: JS to TS, callbacks to async/await, class to functional components
  • Cloud task patterns with best-of-N attempts for difficult migration decisions
  • An automation recipe for tracking migration progress weekly

Before modernizing anything, understand what you are working with. Use the CLI for a quick assessment:

Step 2: Set Up the Migration Infrastructure

Section titled “Step 2: Set Up the Migration Infrastructure”

Before parallel agents start migrating files, set up the tooling that makes migration possible. Do this in Local mode:

Set up the infrastructure for incremental TypeScript migration:
1. Add tsconfig.json with allowJs: true so existing JS files continue to work
2. Configure the build to handle both .js and .ts files
3. Add @types packages for our dependencies (express, react, etc.)
4. Update ESLint to handle both JS and TS files
5. Create a tsconfig.strict.json that new TS files should target
6. Update CI to run type-check on .ts files only (do not fail on .js files)
7. Add a migration tracking file (scripts/migration-progress.json) that counts JS vs TS files per directory
Run the existing test suite to confirm nothing broke.

Divide the codebase into migration batches by directory. Each batch runs in its own worktree. The key rule: each batch must leave the application in a deployable state.

Worktree 1: Utility Layer (lowest risk)

Migrate all files in src/utils/ from JavaScript to TypeScript:
For each file:
1. Rename from .js to .ts
2. Add type annotations to all function parameters and return types
3. Replace require() with import statements
4. Replace module.exports with named exports
5. Replace callbacks with async/await where applicable
6. Fix any type errors reported by tsc
Do NOT change the function signatures or behavior.
Run the test suite after each file migration.
If a test fails, fix the migration (not the test).
Update scripts/migration-progress.json with the new counts.

Worktree 2: Database Layer

Migrate all files in src/lib/db/ from JavaScript to TypeScript.
Same approach as the utility migration: rename, add types, convert imports, replace callbacks with async/await.
Additional requirements for database files:
- Type the query results using Drizzle's inferred types
- Replace raw SQL strings with Drizzle query builder calls where possible
- Add return type annotations that match the actual database response shapes
Run tests after each file. Fix any type errors.

Worktree 3: Service Layer (same pattern, src/services/)

Worktree 4: Route Handlers (same pattern, src/routes/)

Step 4: Use Cloud Tasks for Difficult Migrations

Section titled “Step 4: Use Cloud Tasks for Difficult Migrations”

Some files are harder to migrate than others — deeply nested callbacks, complex class hierarchies, or modules with implicit global state. For these, use cloud tasks with --attempts to explore different migration strategies.

Terminal window
codex cloud exec --env migration-env --attempts 3 "Migrate src/services/legacy-payment-processor.js to TypeScript..."

With three attempts, Codex explores different approaches to the callback flattening and class restructuring. Compare the results: one attempt might preserve the class hierarchy with TypeScript generics; another might flatten to pure functions; a third might use a hybrid approach. Pick the one that is cleanest and easiest to maintain.

Set up a weekly automation that measures migration progress and identifies the next batch:

For React-specific modernization (class components to functional components with hooks), the pattern is the same but the prompts are specific:

Migrate src/components/Dashboard.jsx from a class component to a functional component with hooks:
1. Convert the class to a function component
2. Replace this.state and this.setState with useState hooks
3. Replace componentDidMount, componentDidUpdate, componentWillUnmount with useEffect
4. Replace class methods with const functions or useCallback where appropriate
5. Add TypeScript types for props, state, and any callbacks
6. Rename from .jsx to .tsx
Preserve the exact same rendering behavior and prop interface.
Run the component tests after migration. The tests should pass without changes.
If a test references class-specific APIs (e.g., wrapper.instance()), update the test to use Testing Library patterns.

Migrated file breaks an unmigrated file that imports it. Changing from module.exports to named exports can break require() consumers. Enable esModuleInterop in tsconfig.json and tell Codex: “When converting exports, ensure backward compatibility with CommonJS consumers. If any .js file imports from this module, verify the import still works after migration.”

Type errors cascade across the codebase. When you add types to a utility function, every caller might now have a type error. Use allowJs: true and only type-check .ts files initially. As more files migrate, the type-checking coverage naturally expands.

Cloud task migrates against the wrong base. If your migration infrastructure branch has not been merged to the cloud environment’s default branch, cloud tasks will not see the tsconfig.json or the type packages. Update your cloud environment’s repo map to use the migration branch, or merge the infrastructure changes to main first.

Migration introduces subtle behavior changes. Converting callbacks to async/await can change execution order. Converting class components to hooks can change re-render behavior. Always include “verify identical behavior” in your prompt, and run the existing test suite — do not just rely on the new tests.