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.
tscprints 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
@typespackages 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:
allowJslets.jsand.tsfiles live in the same program, so a.tsfile can import a.jsfile and the other way around.checkJs: falsekeeps TypeScript from reporting errors in files you haven’t touched yet. You’ll opt files in one by one.strict: falseis deliberate. Since TypeScript 6.0,strictdefaults totrue, 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 defaulttypesis 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.noEmitfits projects where a bundler,tsxor a framework already turns code into JavaScript and TypeScript only checks it. Iftscbuilds your app, drop it and setoutDirinstead.moduleandmoduleResolution: usenodenextfor 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:
noImplicitAny: every parameter and variable must have a known type. This produces the most errors and the most useful types.strictNullChecks:nullandundefinedbecome their own types. This one finds real bugs, the “cannot read property of undefined” kind.- The rest of the strict family:
strictFunctionTypes,strictBindCallApply,strictPropertyInitialization,noImplicitThis,useUnknownInCatchVariables. Usually fewer errors each. "strict": truereplaces the individual flags once they all pass.- Optional extras that aren’t part of
strict:noUncheckedIndexedAccess(indexed reads may beundefined) andexactOptionalPropertyTypes.
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.jsand shared declarations insrc/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
UserorOrdertype, make that a sequential step before the parallel ones start. Otherwise three agents write three slightly differentUserinterfaces. - Install
@typespackages 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
tscand 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 asts-jestorbabel-jestwith 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
requiretoimportchanges semantics. Default exports,module.exports = fnand 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, anifreordered. Each one can change behaviour. Reject them and ask for a separate commit. - Silencing the compiler with config.
skipLibCheckis fine; turning off a strict flag to make a branch pass is not. That’s why agents don’t get to edittsconfig.json. - Generated or vendored files. Exclude them in
tsconfig.jsonrather 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.jsonandsrc/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.