TypeScriptAdvanced

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.

DevFieldGuideJuly 29, 2026 (updated July 30, 2026)6 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, convert nothing

allowJs + checkJs: false — coexists with zero breakage.

Step 2

Rename low-risk files first

Utilities and constants, not core business logic.

Step 3

Type the data boundaries

API responses, DB rows, form inputs — where real bugs live.

Step 4

Turn on checkJs

Remaining .js files get checked via inference + JSDoc.

Step 5

Tighten strict mode gradually

One flag at a time, not all of strict at once.

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.

Once files are converted, the built-in TypeScript utility types (Partial, Pick, Omit, and the rest) are what make deriving new shapes from existing ones practical, instead of hand-writing near-duplicate interfaces for every variation.

Handling third-party libraries without type definitions

Not every package ships its own types, and not every package has a community-maintained @types/* package either. When neither exists, TypeScript treats the import as any by default under allowJs, or errors under stricter settings — both are worth handling deliberately rather than leaving to chance:

bash
npm install -D @types/lodash

For a package with genuinely no types available anywhere, a minimal local declaration file scopes the any to exactly that one package instead of leaking untyped values through your whole codebase silently:

ts
// src/types/untyped-package.d.ts
declare module "some-untyped-package" {
  export function doSomething(input: string): number;
}

Even a partial declaration — typing just the functions you actually call, leaving the rest as any — is a real improvement over no declaration at all, since it at least type-checks your own usage at the call sites that matter.

Enforcing the migration in CI, not just locally

A migration that relies on developers remembering not to add new .js files, or not to reintroduce any, drifts the moment anyone's not paying close attention. Two CI checks make the policy self-enforcing instead of aspirational:

yaml
# Fail CI if any new .js files are added outside allowed legacy paths
- name: Check for new JS files
  run: |
    NEW_JS=$(git diff --name-only origin/main...HEAD -- '*.js' ':!legacy/**')
    if [ -n "$NEW_JS" ]; then
      echo "New .js files outside legacy/ are not allowed:"
      echo "$NEW_JS"
      exit 1
    fi
json
// eslint config, enforced in CI
{
  "rules": {
    "@typescript-eslint/no-explicit-any": "error"
  }
}

Running tsc --noEmit in CI on every PR (separate from the actual build step) is the other essential check — it catches type errors introduced by a change even in files the PR didn't directly touch, which is exactly the kind of regression an incremental migration is otherwise prone to.

Common mistakes

Common mistakes
  • Trying to convert the whole codebase in one large pull request. It's slow to review, high-risk to merge, and abandons the entire point of an incremental migration — small, revertible steps.
  • Typing everything as any just to make errors disappear. any opts a value out of type checking entirely, which means the migration adds files without adding real safety — prefer unknown plus a narrowing check when a type genuinely isn't known yet.
  • Enabling full strict mode on day one on a large legacy codebase. Hundreds of simultaneous errors are demoralizing and hard to review meaningfully — enable the individual strict flags incrementally instead, as described above.
  • Converting leaf utility files last instead of first. Files with few dependencies are the lowest-risk, fastest wins — starting with the most central, most-imported files first maximizes the chance of getting stuck deep in a conversion with a half-broken build.
Advertisement

Frequently Asked Questions

Advertisement
DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in TypeScript

View all