A JavaScript codebase does not have to stop for a migration. TypeScript’s checker can read a project one file at a time, and the code keeps running exactly as before, because JSDoc types are comments. This guide takes a working project from “nothing checked” to strict in five steps, with a way to measure progress at each one.
The order matters more than the speed: each step should leave the build green, so the check can run in CI from the first day.
Step 0: install TypeScript and a config that checks nothing yet
Add TypeScript as a development dependency and a script that runs it:
npm install --save-dev typescript
npm pkg set scripts.lint="tsc -p jsconfig.json"Then a jsconfig.json with checking off. checkJs: false keeps tsc quiet about files you have not opted in, and strict: false means the first files you do opt in only report real mistakes, not missing types:
{
"compilerOptions": {
"checkJs": false,
"strict": false,
"noEmit": true,
"target": "es2022",
"module": "nodenext"
},
"include": ["src/**/*.js"]
}npm run lint should now pass. Set module to what the project actually is: nodenext reads "type" in package.json the way Node.js does, and the quick start has a setup per project shape. Writing "checkJs": false rather than leaving it out matters a little: when the option is missing, tsc still reports JavaScript’s own syntax and binding errors in unchecked files (since 4.6), such as a const declared twice, and editors offer spelling suggestions there (since 4.4).
Step 1: opt in one file at a time
A // @ts-check comment at the top of a file turns checking on for that file alone. Start with small modules that many others import, such as formatting helpers and constants, because their JSDoc types then flow into every file that uses them:
// @ts-check
const items = [
{ name: "Tea", price: 4.5, qty: 2 },
{ name: "Cups", price: 12, qty: 1 },
];
/** @param {number} amount */
function formatPrice(amount) {
return "$" + amount.toFixed(2);
}
const total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
console.log(formatPrice(total.toFixed(2)));error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": false,
"noEmit": true,
"strict": false,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["**/*.js"]
} That error is the point of the exercise: toFixed returns a string, and the formatter wanted the number. In production this printed NaN for nobody to notice. Fix what tsc reports, commit, and move to the next file. A file that is not ready can say so with // @ts-nocheck, which wins over everything else.
At this stage the rest of the project is still unchecked, but it already benefits: the JSDoc you add in checked files shows up in editor hovers and completions everywhere the function is used.
Step 2: opt in a folder at a time
Once most files in a folder carry // @ts-check, turn checkJs on and narrow the include list to the folders that are ready. Files the list leaves out are not checked, with one exception worth knowing before you rely on it: a file that an included file imports joins the program and is checked too.
import { formatPrice } from "../../legacy/format.js";
export const label = formatPrice(21);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": false,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["src/checked/**/*.js"]
} export function formatPrice(amount) {
return "$" + Number(amount).toFixd(2);error TS2551: Property 'toFixd' does not exist on type 'number'. Did you mean 'toFixed'?
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": false,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["src/checked/**/*.js"]
} So a folder is “ready” when the files it imports are ready too. If an imported file is not, put // @ts-nocheck at its top until it is. Grow the include list folder by folder until it is ["src/**/*.js"], then remove the // @ts-check comments, which checkJs makes redundant.
Step 3: turn on noImplicitAny
With everything checked, the next gap is parameters TypeScript cannot type. In a relaxed config they are any, so a call with the wrong arguments passes. noImplicitAny reports each one as TS7006:
export function applyDiscount(price, percent) {error TS7006: Parameter 'price' implicitly has an 'any' type.error TS7006: Parameter 'percent' implicitly has an 'any' type.
return price - (price * percent) / 100;
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": false,
"target": "es2022",
"module": "nodenext",
"types": [],
"noImplicitAny": true
},
"include": ["**/*.js"]
} /**
* @param {number} price
* @param {number} percent
*/
export function applyDiscount(price, percent) {
return price - (price * percent) / 100;
}export function applyDiscount(price: number, percent: number) {
return price - (price * percent) / 100;
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": false,
"target": "es2022",
"module": "nodenext",
"types": [],
"noImplicitAny": true
},
"include": ["**/*.js"]
} This is usually the largest step, and it splits well: fix one folder per pull request. Parameters of callbacks passed to typed functions (items.map((item) => …)) are typed from context and need nothing, so the work is mostly exported functions and class methods, which are exactly the places where a type documents the code.
Step 4: turn on strict
strict adds strictNullChecks and the rest of the strict family. The step that changes the most is null checking: find(), querySelector() and Map.get() can return nothing, and tsc now says so:
/** @typedef {{ sku: string, count: number }} StockLine */
/** @type {StockLine[]} */
const stock = [{ sku: "TEA-01", count: 3 }];
/** @param {string} sku */
export function countOf(sku) {
const line = stock.find((l) => l.sku === sku);
return line.count;error TS18048: 'line' is possibly 'undefined'.
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["**/*.js"]
} /** @typedef {{ sku: string, count: number }} StockLine */
/** @type {StockLine[]} */
const stock = [{ sku: "TEA-01", count: 3 }];
/** @param {string} sku */
export function countOf(sku) {
const line = stock.find((l) => l.sku === sku);
return line ? line.count : 0;
}interface StockLine {
sku: string;
count: number;
}
const stock: StockLine[] = [{ sku: "TEA-01", count: 3 }];
export function countOf(sku: string) {
const line = stock.find((l) => l.sku === sku);
return line ? line.count : 0;
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["**/*.js"]
} Replace "noImplicitAny": true with "strict": true. If one member of the family is too much at once (strictPropertyInitialization in a codebase with many classes is the usual one), set it to false next to strict and come back to it later; an option you set yourself wins over strict.
Third-party packages
Packages that ship their own .d.ts files need nothing. For the rest:
- Install the community types when they exist:
npm install --save-dev @types/<name>. The types finder tells you which case a package is in. - Write a small declaration for a package without types, in a
.d.tsfile in your project, covering only what you use. The recipe for untyped packages shows how. - Under
noImplicitAny, an untyped import is TS7016. Before that step it is silentlyany, which is why it surfaces in step 3:
import { slugify } from "legacy-slugify";error TS7016: Could not find a declaration file for module 'legacy-slugify'. '/project/node_modules/legacy-slugify/index.js' implicitly has an 'any' type.
Try `npm i --save-dev @types/legacy-slugify` if it exists or add a new declaration (.d.ts) file containing `declare module 'legacy-slugify';`
export const slug = slugify("Cotton tee");jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": [],
"maxNodeModuleJsDepth": 0
},
"include": ["*.js"]
} Keep skipLibCheck: true (the default for a file named jsconfig.json): it skips checking the .d.ts files themselves, including packages’ errors you cannot fix.
Measuring progress
Two numbers tell you where you are: how many files are checked, and how many errors are left when you try the next step.
tsc --listFilesOnly prints the files in the program without checking them, TypeScript’s own library files included, so filter for your source folder:
npx tsc -p jsconfig.json --listFilesOnly | grep -c "/src/"
grep -rl "@ts-check" src | wc -lTo size the next step before taking it, run tsc with the option turned on from the command line, which overrides the config, and count the errors per file. tsc writes one error per line when its output is not a terminal (or with --pretty false), so a short script can tally them:
// npx tsc -p jsconfig.json --pretty false | node count-errors.js
import { readFileSync } from "node:fs";
const output = readFileSync(0, "utf8");
/** @type {Map<string, number>} */
const perFile = new Map();
for (const line of output.split("\n")) {
// tsc prints one error per line: src/cart.js(12,5): error TS2345: ...
const match = /^(.+?)\(\d+,\d+\): error TS\d+:/.exec(line);
if (match) perFile.set(match[1], (perFile.get(match[1]) ?? 0) + 1);
}
const rows = [...perFile].sort((a, b) => b[1] - a[1]);
for (const [file, count] of rows) console.log(String(count).padStart(5), file);
const total = rows.reduce((sum, [, count]) => sum + count, 0);
console.log(String(total).padStart(5), `errors in ${rows.length} files`);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["**/*.js"]
} npx tsc -p jsconfig.json --noImplicitAny --pretty false | node count-errors.jsThe files at the top of that list are where the next pull requests go. When a number surprises you, tsc --explainFiles says why each file is in the program (which include pattern matched it, or which import pulled it in).
When to stop
The migration is done when strict is on for the whole project. Beyond that:
- Leave some files unchecked on purpose. Bundled output, copies of other projects’ code and one-off scripts can keep
// @ts-nocheck. Write down why at the top of each. - The stricter options are a separate decision.
noUncheckedIndexedAccessandexactOptionalPropertyTypescatch real bugs but report a lot in code that was written without them; choosing a strictness level covers what each one costs. - Keep it from sliding back. Run the check in CI on every pull request, so new code is checked from the day it is written; checking JavaScript in CI has a workflow, and a way to fail only on new errors while old ones remain.
Every example on this page was checked with TypeScript 5.9.
Questions
Do I have to fix every error before I can merge the first step?
No. Start with checkJs off and opt files in with // @ts-check, so tsc only reports errors in files you chose. The check can run in CI from the first day, and each new file you opt in has to be clean.
Should I rename files to .ts instead?
Only if you want a build step. Checked JavaScript gives you the same checker with no change to how the code runs. The JSDoc vs TypeScript comparison covers when .ts files pay off.
When is the migration finished?
When strict is on for the whole project and the files left with // @ts-nocheck are ones you decided not to type. Going further, with the options the strict level adds, is a separate decision.
Sources
- Migrating from JavaScript · TypeScript handbook
- Type Checking JavaScript Files · TypeScript handbook
- tsc CLI Options (--listFilesOnly, --explainFiles) · TypeScript handbook
- TSConfig Reference: checkJs · TypeScript
Last reviewed 21 October 2026.