Guide · Adoption · 21 October 2026 · 14 min read

Migrating a JavaScript codebase to checked JavaScript, step by step

Move an existing JavaScript project to type-checked JavaScript one step at a time: per file, per folder, then noImplicitAny and strict, measuring as you go.

  • Examples checked with TypeScript 5.9

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:

jsconfig.json
{
  "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:

cart.js Error confirmed · TypeScript 5.9.3 · checked 21 October 2026
// @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'.
One file opted in with // @ts-check, while checkJs stays off.
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.

src/checked/cart.js TypeScript 5.9.3 · checked 21 October 2026
import { formatPrice } from "../../legacy/format.js";

export const label = formatPrice(21);
The folder the include list names.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": false,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["src/checked/**/*.js"]
}
legacy/format.js Error confirmed · TypeScript 5.9.3 · checked 21 October 2026
export function formatPrice(amount) {
  return "$" + Number(amount).toFixd(2);error TS2551: Property 'toFixd' does not exist on type 'number'. Did you mean 'toFixed'?
}
Outside the include list, but imported, so it is checked too.
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:

discount.js Error confirmed · TypeScript 5.9.3 · checked 21 October 2026
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;
}
noImplicitAny reports each parameter TypeScript cannot type.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": false,
    "target": "es2022",
    "module": "nodenext",
    "types": [],
    "noImplicitAny": true
  },
  "include": ["**/*.js"]
}
discount.js discount.ts TypeScript 5.9.3 · checked 21 October 2026
/**
 * @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;
}
The same function with @param types.
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:

stock.js Error confirmed · TypeScript 5.9.3 · checked 21 October 2026
/** @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'.
}
Under strict, a value that may be missing must be checked first.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["**/*.js"]
}
stock.js stock.ts TypeScript 5.9.3 · checked 21 October 2026
/** @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;
}
Checking the result of find() before using it.
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.ts file 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 silently any, which is why it surfaces in step 3:
app.js Error confirmed · TypeScript 5.9.3 · checked 21 October 2026
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");
A JavaScript-only package the checker does not read.
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 -l

To 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:

count-errors.js TypeScript 5.9.3 · checked 21 October 2026
// 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`);
A script that counts tsc's errors per file.
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.js

The 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. noUncheckedIndexedAccess and exactOptionalPropertyTypes catch 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

Last reviewed 21 October 2026.