Lesson 79 of 80
You don't rewrite a JS codebase in a weekend — you turn the type checker on, let it stay quiet where it doesn't know things yet, and convert file by file without ever breaking the build.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Nobody migrates a real codebase to TypeScript by stopping the world and rewriting every file. The codebase has to keep shipping. The actual strategy is: turn on just enough TypeScript that it starts checking what it can, let it stay silent about the rest, and then convert files one at a time, in whatever order pays off first — usually the files that change most often or cause the most bugs.
TypeScript is built for this. allowJs lets .js files sit inside a TypeScript project unconverted. checkJs goes one step further and actually type-checks those .js files using JSDoc comments and inference, without you renaming anything. You get real errors on plain JavaScript before you've written a single .ts extension. That's the entire trick: type-checking and file extension are two separate decisions, and you don't have to make them at the same time.
Start here, in tsconfig.json:
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"strict": false,
"outDir": "dist",
"target": "ES2020",
"module": "ESNext"
},
"include": ["src/**/*"]
}
allowJs: true with checkJs: false is stage zero: TypeScript compiles your .js files through unchanged but checks nothing in them. Nothing breaks. Nothing gets stricter. You've just made .ts and .js coexist.
Stage one is flipping checkJs on, file by file, using a comment instead of the global flag:
// @ts-check
// utils/formatCurrency.js
/**
* @param {number} amountInCents
* @param {string} currencyCode
* @returns {string}
*/
function formatCurrency(amountInCents, currencyCode) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode,
}).format(amountInCents / 100);
}
module.exports = { formatCurrency };
// @ts-check at the top of a single .js file opts just that file into checking, using the JSDoc types as the source of truth. Every other file stays untouched. This is how you find real bugs — a caller passing a string where amountInCents should be a number — without converting anything yet.
Stage two is the actual rename, formatCurrency.js → formatCurrency.ts, converting the JSDoc into real syntax:
// utils/formatCurrency.ts
export function formatCurrency(amountInCents: number, currencyCode: string): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode,
}).format(amountInCents / 100);
}
Pick conversion order by blast radius, not alphabetically: leaf utilities and pure functions first (few dependents, easy to get fully typed), framework glue and deeply interconnected modules last. A file with zero internal imports is a safe five-minute conversion. A file that twenty others import is where you'll hit the most any leakage from files you haven't converted yet — save it for when more of its neighbors are typed too.
For code you genuinely can't type correctly yet — a gnarly dynamic object, a third-party callback shape you don't have time to nail down — use // @ts-expect-error instead of just leaving it broken or reaching for any:
// @ts-expect-error — legacy config loader still returns `any`-shaped data, ticket PRAX-482
const rawConfig = loadLegacyConfig();
const port: number = rawConfig.server.port;
strict: true on day one stalls the whole migration. A large existing codebase will produce thousands of errors instantly, which either gets strict turned back off in frustration or produces a PR nobody can review. Start with strict: false, get allowJs/checkJs working file by file, and ratchet individual strict flags (noImplicitAny, strictNullChecks) on later once the bulk of the code is actually typed.@ts-expect-error without a comment explaining why is just technical debt with extra steps. Always write what's broken and why you're not fixing it now next to it — ideally with a ticket reference, like the example above. A bare @ts-expect-error six months later tells nobody anything.any as the finish line. A migration that converts every .js to .ts but types everything as any has changed file extensions, not added safety. any opts a value out of checking entirely — it's a valid temporary stopping point on a specific value you'll circle back to, not a goal.any and unknown applied at project scaleThe same judgment call that shows up when choosing between any and unknown for a single value — how much do I actually know about this, and how much do I want the type checker to trust me — is the entire migration strategy zoomed out to the size of a codebase: every unconverted file is functionally any, and the whole project only gets safer as unconverted files (the parts you know nothing about, to the compiler) shrink in number.