Migrate JavaScript to TypeScript with AI: a safe plan

How to migrate JavaScript to TypeScript with AI agents: a tsconfig that accepts JS, file-by-file order, a strictness ramp, an any budget and tsc as the verify step.

Solviera Teknoloji 13 min read

To migrate JavaScript to TypeScript with AI, you don’t ask an agent to “convert the project”. You set up a TypeScript config that accepts the JavaScript you already have, then let agents convert files in a fixed order, with the type checker and the tests deciding when each step is done. The compiler is the best reviewer you’ll ever give an agent: it’s fast, it never gets tired and it can’t be talked into “looks fine”.

This guide is the hands-on version of that idea: the exact tsconfig.json to start with, how to pick the order of files, how to tighten strictness without a week of red builds, how to keep any under control, how to make type checking the agent’s own verify step and how to split the work across parallel agents without merge conflicts. For the general safety net of a legacy refactor (characterization tests, rollback, review), read refactor legacy code with AI first; this post doesn’t repeat it.

Version-sensitive details here follow the official TypeScript documentation as of October 2026.

Why agents are good at this job

A JS to TS migration is mostly mechanical work with a clear signal at the end of each step. That is exactly what coding agents are good at:

  • The feedback is instant. tsc prints file, line and error code. An agent can read that and fix it without guessing.
  • The work splits naturally. Files and folders can be converted one at a time, so the job breaks into many small, reviewable steps.
  • The boring part is huge. Adding parameter types, writing interfaces for objects that already exist and installing @types packages is the kind of work people put off for years.

The risk is just as clear. An agent under pressure to make the compiler green will reach for any, as casts and @ts-ignore, because they make errors disappear. Most of this guide is about closing those exits.

Step 1: a tsconfig that accepts your JavaScript

The first commit should change no application code. It adds TypeScript and a config that type checks the project while every file is still .js.

npm install --save-dev typescript @types/node
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "strict": false,
    "noEmit": true,
    "incremental": true,
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src"]
}

What each choice does:

  • allowJs lets .js and .ts files live in the same program, so a .ts file can import a .js file and the other way around.
  • checkJs: false keeps TypeScript from reporting errors in files you haven’t touched yet. You’ll opt files in one by one.
  • strict: false is deliberate. Since TypeScript 6.0, strict defaults to true, so leaving it out would switch on every strict check on day one.
  • types: ["node"] matters for the same reason: in TypeScript 6.0 the default types is an empty array, so Node globals aren’t picked up unless you list them. Add "jest" or "vitest/globals" if your tests rely on globals.
  • noEmit fits projects where a bundler, tsx or a framework already turns code into JavaScript and TypeScript only checks it. If tsc builds your app, drop it and set outDir instead.
  • module and moduleResolution: use nodenext for Node apps; for code that a bundler builds, "module": "esnext" with "moduleResolution": "bundler" is usually the right pair.

Add one script and make CI run it:

{
  "scripts": {
    "typecheck": "tsc -p tsconfig.json"
  }
}

Run it once. On a JavaScript codebase with checkJs off, it should pass or show only a few config errors. Fix those, commit, and you have a baseline. From here on, “the build is green” means npm run typecheck and npm test both pass.

Step 2: preview errors with @ts-check before renaming

Before a file is renamed, you can see what TypeScript thinks of it. Add // @ts-check as the first line of a .js file and TypeScript reports errors in it, using JSDoc comments as types:

// @ts-check

/**
 * @param {number} cents
 * @param {string} currency
 * @returns {string}
 */
export function formatPrice(cents, currency) {
  return new Intl.NumberFormat('en', { style: 'currency', currency }).format(cents / 100);
}

This is a good first job for an agent on a large file: “Add // @ts-check to src/orders/pricing.js, fix the errors with JSDoc types only, don’t change behaviour.” You get type safety without a rename, and the later conversion becomes almost mechanical. If you prefer the opposite approach, set checkJs: true and put // @ts-nocheck on the files that aren’t ready yet; then removing that line is the step.

Step 3: pick the order, leaves first

Convert files that import nothing from your own code first, then the files that only import those, and so on up to the entry points. That way every file you convert can lean on types that already exist.

You don’t have to work the order out by hand. madge reads the import graph:

npx madge --leaves --extensions js,jsx src/
npx madge --circular --extensions js,jsx src/

--leaves lists modules with no dependencies, and --circular lists import cycles. Cycles are worth knowing about up front: files in a cycle have to be converted together, so plan them as one step.

A practical batching rule: one step is either one large file or one folder of small files, small enough that the diff fits in one sitting of review. Put the list in a checked-in file such as MIGRATION.md, one line per step, and let agents tick it off. That file becomes the shared plan for every agent and every session.

Step 4: one conversion prompt, one definition of done

Use the same prompt shape for every step, so agents don’t invent their own rules:

Convert src/billing/ from JavaScript to TypeScript.

- Rename with git mv so history follows the files (.js -> .ts, .jsx -> .tsx).
- Add types for parameters, return values and exported objects.
  Prefer types inferred from real usage over wide ones.
- Don't change runtime behaviour. No refactoring, no renaming,
  no "while I'm here" fixes. If you find a bug, note it in
  MIGRATION.md under "Found during migration" and leave it.
- No @ts-ignore, no @ts-nocheck. @ts-expect-error only with a reason
  on the same line. No new `any` beyond the budget in .any-budget.
- Don't edit tsconfig.json, package.json or src/types/.
  If you need a change there, stop and tell me what and why.

Done means: npm run typecheck passes, npm test passes,
npm run lint passes, and MIGRATION.md is updated.

Two lines in that prompt do most of the work. “Don’t change runtime behaviour” keeps the diff reviewable, because a pure type migration should only add annotations, renames and imports. “Don’t edit tsconfig.json” stops the cheapest way to green: loosening the compiler.

Put the stable parts of this prompt in CLAUDE.md or AGENTS.md so every session starts with them; CLAUDE.md and AGENTS.md explains how to share one set of rules between agents.

Step 5: an any budget that only goes down

You won’t get to zero any in one pass, and banning it outright tends to produce worse escapes, like as unknown as Foo. A budget is more honest: you allow a number, and the number only goes down.

First make any and suppression comments visible with typescript-eslint:

// eslint.config.js (excerpt)
rules: {
  '@typescript-eslint/no-explicit-any': 'warn',
  '@typescript-eslint/ban-ts-comment': ['error', {
    'ts-expect-error': 'allow-with-description',
    'ts-ignore': true,
    'ts-nocheck': true,
    minimumDescriptionLength: 10,
  }],
}

ban-ts-comment with these options rejects @ts-ignore and @ts-nocheck and only accepts @ts-expect-error with a written reason of at least ten characters. @ts-expect-error is the better escape anyway: once the error is fixed, the comment itself becomes an error, so it can’t go stale.

Then count explicit any and compare it with a number stored in the repo:

#!/usr/bin/env bash
# scripts/any-budget.sh
count=$(npx eslint src --format json \
  | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const r=JSON.parse(s);console.log(r.flatMap(f=>f.messages).filter(m=>m.ruleId==="@typescript-eslint/no-explicit-any").length)})')
budget=$(cat .any-budget)
echo "explicit any: $count (budget $budget)"
[ "$count" -le "$budget" ] || { echo "any budget exceeded"; exit 1; }

Run it in CI and in the agent’s definition of done. When a step brings the count down, lower .any-budget in the same commit. Tell the agent where the budget lives; agents respect a number much better than “avoid any where possible”.

Watch for the other escapes, which the count doesn’t catch: as casts on values that came from outside (JSON.parse, request bodies), Function and object as parameter types, and a declare module '*' line that turns every import into any. Ask the reviewer, human or agent, to list every new cast in the diff.

Step 6: make type checking the agent’s verify step

An agent that says “done” without running tsc is guessing. Make the check part of how the agent ends a turn, not something you remember to ask for.

In Claude Code, a Stop hook can refuse to let the turn end while the type check fails. Save this as .claude/hooks/typecheck.sh:

#!/usr/bin/env bash
# Exit 2 sends stderr back to Claude and keeps it working.
input=$(cat)
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0   # already retried once; let the turn end
fi
npx tsc -p tsconfig.json --pretty false 1>&2 || exit 2

and register it in .claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": ".claude/hooks/typecheck.sh" }
        ]
      }
    ]
  }
}

When a Stop hook exits with code 2, Claude Code doesn’t stop and shows the hook’s stderr to Claude, so the agent sees the compiler errors and keeps fixing. The stop_hook_active check lets the turn end on the second try instead of looping. The Claude Code hooks guide covers hooks in more depth. A PostToolUse hook on Edit|Write can run a faster check after every edit, though on large projects a full tsc per edit gets slow.

Codex and Gemini CLI don’t read Claude’s hooks, so put the same rule in AGENTS.md or GEMINI.md: “Before you say a task is done, run npm run typecheck and npm test and paste the last lines of the output.” Asking for the output is what makes the instruction stick.

Step 7: ramp up strictness one flag at a time

Once every file is .ts, or one area is, turn the strict checks on in this order, with a separate commit and agent task for each:

  1. noImplicitAny: every parameter and variable must have a known type. This produces the most errors and the most useful types.
  2. strictNullChecks: null and undefined become their own types. This one finds real bugs, the “cannot read property of undefined” kind.
  3. The rest of the strict family: strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables. Usually fewer errors each.
  4. "strict": true replaces the individual flags once they all pass.
  5. Optional extras that aren’t part of strict: noUncheckedIndexedAccess (indexed reads may be undefined) and exactOptionalPropertyTypes.

You don’t have to flip a flag for the whole repo at once. A second config that extends the first and covers only finished folders lets strict code stay strict while the rest catches up:

{
  "extends": "./tsconfig.json",
  "compilerOptions": { "strict": true },
  "include": ["src/billing", "src/utils"]
}

Save it as tsconfig.strict.json and run tsc -p tsconfig.strict.json in CI next to the main check. Each time an agent finishes a folder, it adds the folder to include. When include covers src, move strict: true into the main config and delete the second file.

A caution for strictNullChecks: the fastest fix for “possibly undefined” is the non-null assertion !. A few are fine; dozens mean the agent is hiding the problem. Ask for a guard, a default or a type change instead, and treat new ! like new any during review.

Step 8: split the work across parallel agents

A migration is one of the few jobs where running several agents at once really pays off, because folders are largely independent. The way to make it work is to split by directory and give each agent its own git worktree:

git worktree add ../app-ts-billing -b ts/billing
git worktree add ../app-ts-orders  -b ts/orders
git worktree add ../app-ts-users   -b ts/users

Start one agent in each folder with the step prompt from above, pointed at its directory. A few rules keep the branches from colliding:

  • Shared files have one owner. tsconfig.json, package.json, the lockfile, eslint.config.js and shared declarations in src/types/ change only on the main branch, by you or by one coordinating agent. Folder agents request changes there instead of making them.
  • Shared types go first. If several folders need the same User or Order type, make that a sequential step before the parallel ones start. Otherwise three agents write three slightly different User interfaces.
  • Install @types packages centrally. Two branches that each add a different package rewrite the lockfile and conflict every time.
  • Don’t parallelize a cycle. Folders that import each other in a circle belong to one agent.
  • Merge one branch at a time and type check after each merge. Two branches can each pass tsc and fail together, for example when one changes an exported type the other relies on. Rebase the next branch on the new main before merging it.

Git worktrees for parallel agents covers setup and cleanup in detail. Three or four agents is a sensible start; beyond that, reviewing the branches usually becomes the bottleneck, not writing them.

Step 9: let the tests prove nothing changed

Types tell you the code is consistent with itself. Only tests tell you it still does what it did. During a migration:

  • Run the full suite after each step, not just the converted folder’s tests. A changed export can break a caller elsewhere.
  • Convert test files after the code they test, in a separate step, and don’t change assertions while converting. A test whose expected value changed in a “type-only” commit is a red flag.
  • Check the test runner handles .ts. Vitest runs TypeScript files without extra setup; Jest needs a transform such as ts-jest or babel-jest with the TypeScript preset.
  • Add tests where types don’t reach. Code that parses input, talks to an API or reads environment variables gets any-shaped data at runtime no matter what the types say. A few tests there are worth more than perfect annotations. Write unit tests with AI shows how to get agents to write useful ones.

Common pitfalls

  • Converting require to import changes semantics. Default exports, module.exports = fn and interop differ between CommonJS and ES modules. Keep the module format change as its own step, or leave it out of the migration entirely.
  • Types that lie. An interface written from one example object may miss fields that appear only sometimes. Prefer types written from real data or from the API schema.
  • Behaviour “fixes” hidden in a type commit. == turned into ===, a default parameter added, an if reordered. Each one can change behaviour. Reject them and ask for a separate commit.
  • Silencing the compiler with config. skipLibCheck is fine; turning off a strict flag to make a branch pass is not. That’s why agents don’t get to edit tsconfig.json.
  • Generated or vendored files. Exclude them in tsconfig.json rather than letting an agent “type” them.

Doing it with AgentVera

AgentVera runs Claude Code, Codex, Gemini CLI and other CLI agents side by side per project, which suits a migration split into many small steps.

  • A worktree per folder. From the Git panel create a worktree for each directory and start an agent in it with one click, Claude Code on one folder and Codex on another if you like.
  • File ownership. Give the coordinating agent tsconfig.json, package.json and src/types/**. While it runs, other agents’ edits to those files are refused before they happen and they’re told to ask the owner instead; Claude Code agents are stopped by a hook, other CLIs get the rule in their instructions.
  • Verify. Write “typecheck passes, tests pass, no test assertion changed” in your message. When the turn ends, a separate verifier that can’t edit files runs the allowlisted test commands, reads the diff and marks each criterion pass or fail with evidence.
  • Code review. A second model reads each finished turn and lists findings with file:line, which is a good place to catch new casts and ! assertions.
  • Merge queue. Folder branches land one at a time on the latest main, and your check command, such as npm run typecheck && npm test, runs on the merged result before anything is merged.
  • Parallel team (Pro and Team). An agent can split the migration into up to eight parts with the files each part may touch, run each part in its own worktree, merge the finished parts into one integration branch and run the final check, so you review one pull request.

Wrapping up

Migrating JavaScript to TypeScript with AI works when the compiler and the tests are in charge, not the agent’s confidence. Start with a config that accepts JavaScript and sets strict: false on purpose, convert leaves first in small steps, keep any inside a budget that only shrinks, make tsc the agent’s exit check, ramp strictness flag by flag, and split parallel work by directory with shared files owned by one agent. Each step is boring and reviewable, which is exactly what you want from a migration.

If you’ll be watching agents step by step during the migration, AI pair programming explains when to let the agent drive and when to review every step.

Questions

Can AI convert a JavaScript project to TypeScript?

Yes, and it is one of the jobs agents do well, as long as you give them a working type checker, a fixed order and small steps. Let the agent convert one file or folder at a time, run tsc and the tests after each step, and commit each step separately.

Should I turn on strict mode from the start?

Not in an existing codebase. Since TypeScript 6.0 strict is on by default, so set "strict": false explicitly at first, convert the files, then turn on noImplicitAny, strictNullChecks and the other strict flags one at a time.

How do I stop the agent from using any everywhere?

Make it measurable. Report explicit any with the typescript-eslint rule no-explicit-any, ban @ts-ignore with ban-ts-comment, keep a budget number in the repository and fail CI when the count goes above it. Tell the agent the budget in its instructions.

Can several agents migrate different folders at the same time?

Yes, if each one works in its own git worktree and owns one directory. Shared files such as tsconfig.json, package.json and global type declarations should belong to a single agent, and each branch should be type-checked again after it is merged.

Do I need to rewrite my tests during the migration?

No. Keep the existing tests running against the converted code first; they are what tells you the behaviour did not change. Convert test files after the module they cover, and avoid changing assertions in the same step.

Bring your agents to one desk.

Download AgentVera for free; your installed CLIs are ready to go.

More posts