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.
- 1Coexist first
- 2Check JavaScript before converting it
- 3Convert from the leaves inward
- 4Keeping it fast
- 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:
{
"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:
// @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.
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-checkplus 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.