TypeScript

How to Migrate a JavaScript Codebase to TypeScript Incrementally

A step-by-step approach to adopting TypeScript in an existing JavaScript project without a risky big-bang rewrite.

Ava ChenJuly 12, 20263 min read
Share:

Rewriting a working JavaScript codebase in TypeScript all at once is how migrations stall for months. TypeScript is explicitly designed to support incremental adoption instead — here's the practical path.

Step 1: Add TypeScript without converting anything

bash
npm install -D typescript
npx tsc --init

In tsconfig.json, start permissive:

json
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "strict": false,
    "noEmit": true
  }
}

allowJs lets .js and .ts files coexist in the same project. checkJs: false means existing JS files aren't type-checked yet — nothing breaks.

Step 2: Rename files one at a time, starting at the edges

Start with files that have few dependencies — utility functions, constants, types-only modules — not your most central business logic. Renaming utils.js to utils.ts and fixing the handful of type errors TypeScript surfaces is low-risk and builds confidence in the process.

bash
git mv src/utils/format.js src/utils/format.ts

Fix whatever errors appear, commit, move to the next file. Resist the urge to convert everything in one PR — small, reviewable, revertible changes are the entire point of doing this incrementally.

Step 3: Type your data boundaries first

The highest-value types are the ones at your application's boundaries: API responses, database rows, form inputs. These are where bugs from assuming the wrong shape actually happen.

ts
interface ApiUser {
  id: string;
  name: string;
  email: string;
}
 
async function fetchUser(id: string): Promise<ApiUser> {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

Step 4: Turn on checkJs for the remaining files

Once most of the codebase is converted, flip checkJs: true so the remaining .js files get type-checked too (using inferred types and JSDoc comments where needed). This surfaces the last batch of issues without requiring every file to be renamed first.

ts
/**
 * @param {string} name
 * @returns {string}
 */
function greet(name) {
  return `Hello, ${name}`;
}

Step 5: Tighten strict mode gradually

Turning on "strict": true all at once on a large codebase produces hundreds of errors simultaneously. Instead, enable the individual flags it bundles one at a time, fixing each batch before moving to the next:

json
{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true
  }
}

strictNullChecks in particular tends to surface the most real bugs (places where null/undefined weren't actually handled) — it's worth prioritizing even if you leave the rest of strict off longer.

What "done" looks like

You don't need 100% strict-mode coverage to call a migration successful. A codebase that's 80% converted with strict mode on the most critical modules (data layer, API boundaries, shared utilities) catches the overwhelming majority of type-related bugs. Chasing the last 20% inside legacy files nobody touches often isn't worth the time — leave them as .js under allowJs indefinitely if that's where the cost/benefit line falls.

Advertisement
Ava Chen
Ava Chen

Senior Software Engineer

Ava writes about frontend architecture, React, and TypeScript. Previously built developer tools at scale-up startups.

Related Articles