Migrating JavaScript to TypeScript

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

Migrating JavaScript to TypeScript should be a sequence of small checked moves, not a heroic rewrite. The safe path is to let TypeScript see JavaScript first, type the boundaries, rename files when they are ready, and tighten strictness one flag at a time.

This is a tool-shaped lesson. The snippets are read-only config, reference JavaScript, and terminal commands for a real repository.

Let TypeScript see JavaScript first

The first migration step is usually not "rename every file." It is "make the checker aware of the files that already exist."

A starter migration config can include JavaScript while keeping output owned by the existing build:

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "noEmit": true,
    "strict": false,
    "moduleResolution": "Bundler",
    "target": "ES2022",
    "module": "ESNext"
  },
  "include": ["src/**/*.js", "src/**/*.ts"]
}

allowJs lets JavaScript files be part of the TypeScript program. checkJs: false means those files can be included without receiving full JavaScript diagnostics yet. That gives TypeScript enough visibility to understand imports, declarations, and renamed files as you move.

Then add the one script that will survive the whole migration:

{
  "scripts": {
    "typecheck": "tsc --noEmit"
  }
}

Run it before every migration pull request:

npm run typecheck

At this stage, passing typecheck does not mean the JavaScript is fully typed. It means the project is ready for incremental changes without TypeScript emitting files or fighting the existing runtime pipeline.

Type JavaScript with JSDoc

You can add real type information to .js files before changing extensions. JSDoc is useful when a file is risky to rename because it is imported from many places, covered by fragile tests, or owned by another team.

Reference code:

/**
 * @typedef {"free" | "pro" | "enterprise"} Plan
 *
 * @typedef {object} Account
 * @property {string} id
 * @property {Plan} plan
 * @property {number} seats
 */
 
/**
 * @param {Account} account
 * @returns {number}
 */
function monthlyLimit(account) {
  if (account.plan === "enterprise") {
    return account.seats * 100;
  }
 
  if (account.plan === "pro") {
    return account.seats * 10;
  }
 
  return 1;
}

Turn on JavaScript checking for a small folder or a short-lived branch:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "checkJs": true
  },
  "include": ["src/billing.js"]
}

Then run:

npx tsc -p tsconfig.migration.json --noEmit

This is not busywork. JSDoc lets you annotate boundaries in JavaScript: parameters, returns, object shapes, and callback contracts. It is especially useful for files that cannot move to .ts until the surrounding imports and tests settle.

When the JSDoc starts looking like a TypeScript file written in comments, that is your signal to rename:

git mv src/billing.js src/billing.ts
npm run typecheck

After the rename, move the boundary types into TypeScript syntax and delete the comment ceremony. Keep the pull request narrow: one file, one import seam, one set of tests.

Type boundaries before internals

The migration order that survives real repos is boundary first, internals later.

Good early targets:

  • API clients and decoders where unknown should become validated domain data.
  • Configuration objects read at startup.
  • Shared model types used by many files.
  • Public functions called by other packages or teams.
  • Test factories that create common domain values.

Bad early targets:

  • A giant utility folder nobody owns.
  • Generated files.
  • A UI screen with thirty unrelated cleanup ideas.
  • A file you can only type by changing half the app.

The boundary-first rule matches the course rule from Section 2: annotate boundaries, infer the middle. In a migration, the boundary is the seam where wrong shapes enter or leave a file. Once that seam is typed, the checker can help you improve the inside gradually.

Use unknown at untrusted edges. A blanket checker-off-switch type can make a migration look green while preserving the bug. unknown forces the next line to narrow, parse, or reject the value before use, which is exactly the habit Section 13 built.

Reference TypeScript shape:

type Plan = "free" | "pro" | "enterprise";
 
function isPlan(value: unknown): value is Plan {
  return value === "free" || value === "pro" || value === "enterprise";
}
 
function readPlan(raw: unknown): Plan {
  if (isPlan(raw)) {
    return raw;
  }
 
  return "free";
}

That tiny boundary check gives the rest of the file a real Plan. It is better than teaching the checker to stop asking questions at the exact place where runtime data is least trustworthy.

Tighten strictness as a ratchet

Strictness is a ratchet: move it forward, keep it forward, and avoid mixing five policy changes in one pull request.

A migration can have a staged config:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": false,
    "noUncheckedIndexedAccess": false,
    "exactOptionalPropertyTypes": false
  }
}

One team might start by turning on noImplicitAny for new TypeScript files. Another might enable strictNullChecks in a leaf package first.

The sequence is less important than the rule:

Pick one flag.
Pick one folder or package.
Fix the diagnostics without broad escape hatches.
Add the flag to CI for that scope.
Repeat.

Keep a short migration ledger:

Current rule:
  src/billing/** runs with checkJs true.
 
Next rule:
  Enable noImplicitAny for src/billing and src/pricing.
 

That prevents the "temporary" loose setting from becoming architecture.

Shrink the risk:

  • Rename files with git mv so history is readable.
  • Keep formatting-only changes separate from type changes.
  • Migrate leaf modules before shared central modules when possible.
  • Add or update tests around behavior before the type rewrite.
  • Keep temporary escape hatches searchable and owned.
  • Stop after the smallest slice that makes CI stricter than it was yesterday.

The goal is not to make the diff look impressive. The goal is to make the next bug harder to write.

Next lesson teaches the survival skill you need once the repo is typed: reading unfamiliar declarations from TypeScript itself, installed packages, and DefinitelyTyped without panic.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion