Topic 15.4
Migrating a JavaScript Codebase
In one line
You don't rewrite a JavaScript project in one go. You let TypeScript read the JavaScript, rename files one at a time, and turn the strict checks on one by one, so the project keeps working and the error count only goes down.
Think of it like this
Real-life example: Renovating a house you still live in. You don't tear it down and rebuild. You fix one room at a time, and each night the kitchen still works. A migration is the same: every day the app still builds and ships, and every week a few more rooms are "inspected" (type-checked). The only rule is never make a finished room worse.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Migration
- Moving a project from one technology to another in steps, here from JavaScript to TypeScript.
- allowJs
- A tsconfig option that lets TypeScript include .js files in the project.
- checkJs
- A tsconfig option that makes TypeScript report type errors in .js files too.
- @ts-check
- A comment at the top of one JavaScript file that turns checking on for that file only.
- @ts-nocheck
- A comment that turns checking off for one file, useful for files you aren't ready to migrate.
- JSDoc
- Comments like /** @param {number} x */ that describe types inside JavaScript.
- Leaf file
- A file that imports nothing from your own project. Start converting here.
- any budget
- A number that limits how many anys the project may contain, lowered over time.
- Ratchet
- A rule that can only get stricter, never looser, like the click of a ratchet tool.
- Codemod
- A script that changes many source files automatically by understanding their syntax.
Step by step
01A JavaScript file with two bugs
Two functions and two calls, written the usual JavaScript way. The first call passes a string where a number is wanted ("100"), the second passes a misspelled field (nme).
export function addTax(price, rate) {
return price + price * rate;
}
export function label(user) {
return user.name.toUpperCase();
}
console.log(addTax("100", 0.18));
console.log(label({ nme: "Asha" }));02Add a quiet tsconfig
allowJs and checkJs let TypeScript read and check the file, noEmit keeps it from writing output, and strict: false keeps the first run quiet. types: [] is the new default (Topic 11.4).
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": false,
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"types": []
},
"include": ["src"]
}03Run tsc: quiet, then loud
With strict: false the file passes with no errors, even though it has two bugs: nothing is typed, so every parameter is any. Change strict to true and the first wall appears. This is the real output of TypeScript 7.0.2 on that file.
$ npx tsc -p . # "strict": false
$ # no output: exit code 0
$ npx tsc -p . # "strict": true
src/price.js(1,24): error TS7006: Parameter 'price' implicitly has an 'any' type.
src/price.js(1,31): error TS7006: Parameter 'rate' implicitly has an 'any' type.
src/price.js(5,23): error TS7006: Parameter 'user' implicitly has an 'any' type.04Types inside JavaScript: JSDoc and @ts-check
Add // @ts-check and JSDoc types to the file. The two real bugs now appear (even with checkJs: false, because of the comment), under the loose strict: false.
// @ts-check
/**
* @param {number} price
* @param {number} rate
* @returns {number}
*/
export function addTax(price, rate) {
return price + price * rate;
}
/** @typedef {{ name: string }} User */
/** @param {User} user */
export function label(user) {
return user.name.toUpperCase();
}
console.log(addTax("100", 0.18));
console.log(label({ nme: "Asha" }));05What the checker says now
The two bugs that were invisible are now found. This is the loop of a migration: add a type, find a bug, fix it, commit.
$ npx tsc -p .
src/price.js(19,20): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
src/price.js(20,21): error TS2561: Object literal may only specify known properties, but 'nme' does not exist in type 'User'. Did you mean to write 'name'?06Count the budget
To put a ceiling on any, set the rule to warn and cap the number of warnings. In a scratch project (typescript-eslint 8.71.1 recommended, three anys in a file), the same command passes or fails only because of the number.
$ npx eslint src --max-warnings 2
src/legacy.ts
1:30 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
1:36 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
4:21 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
✖ 3 problems (0 errors, 3 warnings)
ESLint found too many warnings (maximum: 2).
$ echo $?
1
$ npx eslint src --max-warnings 3 # same warnings listed
$ echo $?
007The order of the strict flags
Move one stage at a time and keep every stage green before the next. The diagram is a typical order; your project may differ.
Try it yourself
- 1
Walk the ladder
In the first example, change the tsconfig note in your head: the example runs with
strict: false. Now add: numbertopriceandrateonly, andlabel(user: { name: string }). Predict the two errors you will see, then compare with Step 1. - 2
Predict the flag
In Step 2, which line would be an error if
strictNullCheckswere off? (None.) Which if it is on? Read the message. Then fix the function by returningstring | undefinedand using?., as in Step 3. - 3
Start your own count
In a real project, run
grep -rEc ": any|as any" srcand write the total at the top of an issue. Your first CI rule: this number may not go up.
Code & diagrams
This example overrides the compiler options to { strict: false, exactOptionalPropertyTypes: false } (strict: false alone gives TS5052). Everything is any, so tsc reports nothing, and both bugs reach the console. Remove the tsconfig line mentally: with the editor defaults you would see three TS7006 errors.
Console
(nothing was printed)Expected console output
10018
Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')Typing price and user is enough to reveal both bugs. rate is still an implicit any, and the loose options don't complain. Turn that on in the next step.
Console
(nothing was printed)Expected console output
10018
Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')find returns T | undefined, but with strictNullChecks off the | undefined is invisible. This example turns only that flag on. The second call crashes at runtime exactly where the error points.
Console
(nothing was printed)Expected console output
VITE
Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')The same logic, now honest: the bugs were fixed where they entered, firstLong says it may return undefined, and the file passes with no overrides. The last line prints undefined for the empty case instead of crashing.
Console
(nothing was printed)Expected console output
118
ASHA
VITE undefinedBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Turn on noImplicitAny too early
In the first example, remove the tsconfig override (use the editor defaults) or, in a project, add "noImplicitAny": true to the loose options.
Break #2
Check a whole JavaScript project with strict on
In the scratch project, set "strict": true with allowJs and checkJs.
Break #3
Spend the budget
In a project whose rule is no-explicit-any: "warn", add one more any and run npx eslint src --max-warnings 2 with a budget of two.
Myth vs fact
Myth
A migration means rewriting everything in one pull request.
Fact
TypeScript is built for gradual adoption: allowJs mixes both languages, and you can move one file at a time while the app keeps shipping.
Myth
// @ts-ignore is a quick way to finish a migration.
Fact
It hides errors and never reports itself. // @ts-expect-error at least tells you when it is no longer needed.
Myth
Once a file is .ts it is type safe.
Fact
A .ts file full of any and assertions is as safe as JavaScript with extra steps. Track the any count and the strict flags, not just the extension.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Rename in a commit that changes nothing else. Git detects renames by content similarity; a rename plus edits can look like a delete and an add, and
git blameloses the file's history. - ▸
strict: falseplus selected flags equals the same compiler asstrict: trueminus the flags you left off, so you can raise each flag in a separate pull request and find regressions fast. TypeScript 6.0 madestrictdefault to true, so a migrated project should write"strict": falseexplicitly while it is in the middle. - ▸
exactOptionalPropertyTypesneedsstrictNullChecks(without itstrict: falsealone fails withTS5052, as you saw), andnoUncheckedIndexedAccessis easier to add afterstrictNullChecksbecause it produces| undefinedtypes that only matter once null checks are on. - ▸
Large teams use *per-directory* tsconfigs with
extendsand project references (Topic 12.4): converted packages get the strict config, legacy ones keep the loose config, and CI runs both.
Remember this
- 1
The problem this solves: you have a working JavaScript project, maybe thousands of files, and you want TypeScript's safety without stopping the team for months. Switching everything on at once buries you in errors. A migration is a plan that moves in small steps, each of which keeps the project building.
- 2
Recall before you start. Topic 11.6:
allowJs,checkJsand// @ts-checkcheck JavaScript files. Topic 12.2:strictis a bundle of flags, each of which you can turn on alone. Topic 1.6: an unannotated parameter is an implicitany(andnoImplicitAnyreports it). Topic 15.2 gave you the lint rule that countsany. - 3
Step 1: let TypeScript read your JavaScript. Add a
tsconfig.jsonwithallowJs: true(TypeScript accepts.jsfiles too) andcheckJs: true(and reports errors in them). Start withstrict: falseso the first run is quiet. On the scratch project below the original JavaScript produced no errors withstrict: false, and the same code understrict: trueproduced threeTS7006errors. That difference is exactly the size of the job. - 4
Step 2: check a few files first.
checkJson a whole old codebase can show thousands of errors. Instead, keepcheckJs: falseand put// @ts-checkat the top of the files you are ready for. Real output: withcheckJs: false, a file with// @ts-checkand JSDoc types still reported its two errors; files without the comment stayed silent. The opposite tool,// @ts-nocheck, silences a file you aren't ready for whencheckJsis on. - 5
JSDoc (comments like
/** @param {number} price */) lets you write types inside JavaScript, with no build step. TypeScript reads them in checked JS files. It is useful for the first steps and for files that must stay.js(config files, scripts). Topic 11.6 covers it in depth. TypeScript 7 changed the JSDoc rules (for example@enumand Closure-style function types are gone), so prefer the plain@param,@returns,@typedefforms. - 6
Step 3: rename file by file, from the leaves. A *leaf* is a file that imports nothing from your own code (a helper, a constants file). Rename
price.jstoprice.ts, add types to the exported functions, fix what the checker reports, commit. Work towards the entry points, so each file you convert already has typed imports. Rename in one commit with no other change, so Git sees a rename and keeps history. - 7
**Step 4: keep an
anybudget.** In a big migration you will writeanyin places you will return to. Count them and make the count a number that may only fall. The scratch run below used the lint ruleno-explicit-anyas"warn"andeslint --max-warnings 3: with threeanys and a limit of 2 it exits 1, with a limit of 3 it exits 0. Lower the limit each time you remove some. This is how the ratchet works in CI (CI is the server that runs your checks on every change; the rule comes from Topic 15.2). - 8
Step 5: turn strict flags on one at a time.
strictis a bundle (Topic 12.2). Withstrict: falseyou can enable the flags separately: usuallynoImplicitAnyfirst (it forces a type on every parameter), thenstrictNullChecks(the biggest: everyundefinedbecomes visible), then the rest, and lastnoUncheckedIndexedAccessandexactOptionalPropertyTypes, which are extra. The four live examples below are this exact journey on one file. When all flags are on, replace the list withstrict: true. - 9
Tools help but don't finish the job. A codemod is a script that edits many files by understanding their syntax:
jscodeshift(17.4.0) andts-morph(28.0.0) are the usual libraries.ts-migrate, Airbnb's migration tool, was last published in November 2022 (0.1.35 on npm), so check that it still fits your versions. Codemods can rename files and insertanyor// @ts-expect-errorcomments to get you to green; the real value, the types, still comes from people reading the code. - 10
A common trap: **
@ts-ignoreeverywhere.** It makes the error count zero without removing a single bug. Prefer// @ts-expect-error: reason, which complains when the error goes away (Topic 15.3), so the comments clean themselves up as you improve the code.
Explain it without notes
How do allowJs, checkJs, // @ts-check and // @ts-nocheck differ?
In what order would you enable the strict flags in a legacy project, and why?
What is an any budget and how do you enforce it?
Why is @ts-expect-error better than @ts-ignore during a migration?
Practice
Convert this JavaScript function to a JSDoc-typed function (still .js, with // @ts-check) so that average("3") is reported (and say what average([]) does):
``js
function average(nums) {
return nums.reduce((a, b) => a + b, 0) / nums.length;
}
Write the three tsconfig fragments for the stages: (1) JS allowed and quiet, (2) only noImplicitAny on, (3) only strictNullChecks added. Which compiler option makes exactOptionalPropertyTypes fail if you forget it?
Trade-offs
- ↔
Gradual migration keeps the product shipping but leaves a long period where half the project is checked and half is not. Put the progress (strict flags on,
anycount) where the team sees it. - ↔
Automated tools can convert hundreds of files in an afternoon, but with
anyand suppression comments. They move the work, they don't remove it: schedule the follow-up.
Done when you can
I can set up
allowJsandcheckJsand explain what each does.I can use
// @ts-checkand JSDoc to check one JavaScript file.I can list the order in which I would enable strict flags and why.
I can enforce an
anybudget in CI.I can explain why
@ts-expect-errorbeats@ts-ignore.