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 without converting anything
npm install -D typescript
npx tsc --initIn tsconfig.json, start permissive:
{
"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.
git mv src/utils/format.js src/utils/format.tsFix 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.
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.
/**
* @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:
{
"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.