EduFlow

Section 5 · Shipping TypeScript

Migrating JavaScript to TypeScript, and Keeping It Fast

18 min4 min readPut in order

Lesson at a glance

Migrate a JavaScript codebase to TypeScript one file at a time while it keeps shipping, and keep type-checking fast as the codebase and its types grow.

  1. 1Coexist first
  2. 2Check JavaScript before converting it
  3. 3Convert from the leaves inward
  4. 4Keeping it fast
  5. 5Where to go from here

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

The migration I led took a 400,000-line JavaScript checkout and order system to strict TypeScript over about eighteen months, with no feature freeze and no long-lived branch. We shipped every day of it. The type-level techniques in this course matter, but on a real codebase the process decides whether a migration finishes. This lesson is that process, plus how to keep type-checking fast once you are done.

Coexist first

Start by letting TypeScript and JavaScript live together:

tsconfig.json
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "noEmit": true,
    "strict": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "target": "es2022",
    "skipLibCheck": true
  },
  "include": ["src"]
}

allowJs brings .js files into the program, so .ts files can import them and vice versa. noEmit leaves building to the existing bundler; tsc --noEmit runs in CI only as a checker. And strict is on from day one, but it only applies to .ts files, so new code is held to the full standard while old code is left alone. Merge this on main in a single small pull request. Nothing about the app changes.

Check JavaScript before converting it

You can get real checking in a .js file without renaming it. Add // @ts-check at the top, and TypeScript checks the file, reading types from JSDoc comments:

src/money.js
// @ts-check

/** @typedef {'USD' | 'EUR' | 'EGP'} Currency */
/** @typedef {{ cents: number; currency: Currency }} Money */

/**
 * @param {Money} a
 * @param {Money} b
 * @returns {Money}
 */
export function add(a, b) {
  if (a.currency !== b.currency) throw new Error('Currency mismatch');
  return { cents: a.cents + b.cents, currency: a.currency };
}

That is the same checker, with the same rules, on a plain JavaScript file. Use it on the hottest, riskiest files first. JSDoc can even import types from .ts files with @import or import('./types').Order, so a JavaScript file can use the domain types you have already written.

Convert from the leaves inward

When you do rename files, the order matters. Draw (or generate) the import graph and start at the leaves: modules that import nothing else from your codebase, such as money helpers, formatting, constants and the domain types. Then convert the modules that depend only on converted ones, and keep working inward toward the entry points.

Import graph of the checkout. The checkout page imports the API client and the cart store, which both import money and format utilities. The leaves, money and format, are already TypeScript; the API client and cart store are in progress; the checkout page is still JavaScript. Conversion moves from the bottom up. checkoutPage.js apiClient.ts cartStore.js money.ts format.ts in progress done first last
Convert leaves first, so each new TypeScript file imports typed code

Converting an entry point first gives you a .ts file whose imports are all untyped, which means a lot of any and very little checking. Converting a leaf first gives every later file real types to build on.

Alongside the leaves, prioritise the boundaries: the API client, the parsers, the domain models. A typed Order and a parsed API response protect every file that touches them, converted or not. That is why sections 1, 2 and 4 of this course matter more for a migration than section 3.

When the last .js file is gone, remove allowJs. Then ratchet up the extra flags from tsconfig Strictness That Pays Off, noUncheckedIndexedAccess first, one at a time, suppressing existing errors with // @ts-expect-error and a ticket so new code meets the bar immediately.

Keeping it fast

On a large codebase, type-checking speed becomes a developer-experience problem. Measure before you guess: tsc --noEmit --extendedDiagnostics prints check time, memory and the number of type instantiations. A sudden jump in instantiations almost always comes from a heavy type-level utility, like the recursive Paths from Recursive Types and Knowing When to Stop, applied to a big type. --generateTrace <dir> records where the time goes, file by file.

The structural fixes are well established. Turn on incremental so unchanged files are not re-checked. Split a large repository into project references (composite: true in each package, tsc -b at the root), so each part is checked once and its declarations reused. Prefer interfaces that extend each other over long chains of & intersections for big object types, since the compiler caches interface relationships more effectively. And annotate the return types of exported functions, which saves the checker from re-inferring them and keeps declaration output stable.

Where to go from here

You have used types to design states, steered inference, written type-level code with restraint, built the patterns real codebases rely on, and set up the compiler to back them. The habit to keep is the one from the first lesson: before you write a type, name the bug it prevents. Then pick the simplest type that prevents it. The exercise below puts the migration playbook in order; the final assessment covers the whole course.

Key takeaways

  • Migrate incrementally with allowJs, so JavaScript and TypeScript files live side by side and the app ships every day.
  • // @ts-check plus JSDoc types gives a JavaScript file real checking before you rename it.
  • Convert leaf modules first and work toward the entry points, so each newly converted file imports typed code.
  • Type the boundaries (API responses, domain models) early; they give the most protection per hour spent.
  • Measure type-checking with --extendedDiagnostics, use incremental builds and project references, and keep heavy type-level code out of hot paths.

Further reading

Check your understanding

4 questions

Question 1 of 4

You are starting a migration of a 1,500-file JavaScript app. Which first step keeps the app shippable?

Question 2 of 4

In which order should you convert modules?

Question 3 of 4

tsc --extendedDiagnostics shows 9 million type instantiations, most from one file. What is the likely cause?

Question 4 of 4

How does // @ts-check at the top of a .js file help a migration?

Practice

Put in order

Put the migration playbook in order

Cartwheel’s order-management app is 1,500 JavaScript files and must keep shipping every day. Put these migration steps in the order the lesson recommends, from the first pull request to the last.

Use the arrow buttons to reorder.

  1. 1Add // @ts-check and JSDoc types to the API client and domain model files
  2. 2Convert the modules that depend on already-converted ones, working toward the entry points
  3. 3Rename leaf modules (helpers that import nothing internal) from .js to .ts
  4. 4Remove allowJs once no .js source files remain
  5. 5Add a tsconfig with allowJs, noEmit and strict, and run tsc --noEmit in CI next to the existing build
  6. 6Turn on noUncheckedIndexedAccess and other extra flags one at a time, marking old errors with @ts-expect-error