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.
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
.tsandtscreports 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/nodeCreate a tsconfig.json that accepts JavaScript, so you can migrate gradually:
{
"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 --checkJsThe 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/jestIf a package has no types at all, declare it once and move on:
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:
- Rename
.jsto.ts, or.jsxto.tsx. - Replace
requireandmodule.exportswithimportandexportwhere your module setup allows it. - Type function parameters and return values from how they're actually called.
- Export
interfaceortypedeclarations for objects that cross module boundaries.
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 buildType-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 keepesModuleInteropon.- Objects built up property by property. TypeScript infers the shape from the first assignment. Declare the full type up front instead.
thisin plain functions. Convert to methods or arrow functions, or annotatethisexplicitly.- JSON imports. Need
resolveJsonModule, and under NodeNext an import attribute:import data from "./data.json" with { type: "json" }.