Skip to content
CodeMorph
Learn

How to migrate a JavaScript project to TypeScript

A practical, step-by-step plan for moving a real JavaScript codebase to TypeScript without a feature freeze, from tsconfig to strict mode.

Beginner4 min readUpdated 11 October 2026

Most TypeScript migrations don't fail on difficulty. They fail on momentum: a few files get converted, the error count looks endless, and the work quietly stops. This guide is a plan that keeps moving, whether you migrate by hand or with CodeMorph.

Before you start

  • Commit everything. Work on a branch so every step is reversible.
  • Make sure your tests pass. Tests are how you'll prove behavior didn't change.
  • Agree on the goal. "Every file is .ts and tsc reports zero errors" is a finish line. "Add some types" is not.

1. Add TypeScript alongside your JavaScript

Install the compiler and type definitions for your runtime:

npm install --save-dev typescript @types/node

Create a tsconfig.json that accepts JavaScript, so you can migrate gradually:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "allowJs": true,          // JS and TS can import each other
    "checkJs": false,         // don't type-check JS files yet
    "strict": false,          // tighten later, one flag at a time
    "noEmit": true,           // or set outDir if tsc builds your code
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

If a bundler such as Vite or Next.js builds your app, use "module": "ESNext" and "moduleResolution": "Bundler" instead, and keep noEmit on: the bundler compiles, tsc only checks.

2. Measure your baseline

Ask the compiler how far you are from done, without changing a file:

npx tsc --noEmit --checkJs

The error count is your baseline. Write it down. On an untyped codebase it's often in the thousands, and that's fine: it's a measurement, not a verdict.

3. Install types for your dependencies

Many packages ship their own types. For those that don't, install the community definitions:

npm install --save-dev @types/express @types/jest

If a package has no types at all, declare it once and move on:

src/types/vendor.d.ts
declare module "legacy-lib";

4. Convert in dependency order, leaves first

This is the step that decides whether a migration succeeds. Types flow upward through imports. If you convert models/order.js before lib/db.js, you have to guess what db.find() returns. Convert lib/db.js first, and the answer is sitting in a typed file.

So start with leaf modules, files that import nothing else from your project, and work upward toward your entry point. To find them by hand, a tool like madge helps:

npx madge --leaves src/
npx madge --circular src/

Files in a circular import have to be converted together. CodeMorph computes this order for you, groups cycles automatically and splits the work into batches.

5. Convert a file

For each file:

  1. Rename .js to .ts, or .jsx to .tsx.
  2. Replace require and module.exports with import and export where your module setup allows it.
  3. Type function parameters and return values from how they're actually called.
  4. Export interface or type declarations for objects that cross module boundaries.
src/lib/cart.ts
import { db } from "./db";
 
export interface CartItem { sku: string; qty: number; price: number }
export interface Cart { items: CartItem[] }
 
export async function addToCart(userId: string, item: CartItem): Promise<Cart> {
  const cart = await db.cart.find(userId);
  cart.items.push(item);
  return db.cart.save(userId, cart);
}

Resist any. If you genuinely can't type something yet, use unknown and narrow it, or leave a comment and come back.

6. Verify every batch

After each batch, run the same three checks:

npx tsc --noEmit
npm test
npm run build

Type-check the whole project, not just the files you changed: converting a file can break the files that import it. Commit when all three pass, then move up to the next batch.

7. Turn on strict mode

Once every file is TypeScript, switch on strictness gradually. See Turning on strict mode without drowning in errors.

8. Close the door behind you

When nothing JavaScript remains, set "allowJs": false so new .js files can't sneak back in, and add tsc --noEmit to CI.

Common pitfalls

  • require("./x").default. A CommonJS file that imports a converted ES module may need .default. Convert both sides, or keep esModuleInterop on.
  • Objects built up property by property. TypeScript infers the shape from the first assignment. Declare the full type up front instead.
  • this in plain functions. Convert to methods or arrow functions, or annotate this explicitly.
  • JSON imports. Need resolveJsonModule, and under NodeNext an import attribute: import data from "./data.json" with { type: "json" }.
Next guideTurning on strict mode without drowning in errors

Let CodeMorph do the heavy lifting.

Download the beta, open a project and see your migration plan in under a minute.