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, convert nothing
Step 2
Rename low-risk files first
Step 3
Type the data boundaries
Step 4
Turn on checkJs
Step 5
Tighten strict mode gradually
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.
Once files are converted, the built-in TypeScript utility types (Partial, Pick, Omit, and the rest) are what make deriving new shapes from existing ones practical, instead of hand-writing near-duplicate interfaces for every variation.
Handling third-party libraries without type definitions
Not every package ships its own types, and not every package has a community-maintained @types/* package either. When neither exists, TypeScript treats the import as any by default under allowJs, or errors under stricter settings — both are worth handling deliberately rather than leaving to chance:
npm install -D @types/lodashFor a package with genuinely no types available anywhere, a minimal local declaration file scopes the any to exactly that one package instead of leaking untyped values through your whole codebase silently:
// src/types/untyped-package.d.ts
declare module "some-untyped-package" {
export function doSomething(input: string): number;
}Even a partial declaration — typing just the functions you actually call, leaving the rest as any — is a real improvement over no declaration at all, since it at least type-checks your own usage at the call sites that matter.
Enforcing the migration in CI, not just locally
A migration that relies on developers remembering not to add new .js files, or not to reintroduce any, drifts the moment anyone's not paying close attention. Two CI checks make the policy self-enforcing instead of aspirational:
# Fail CI if any new .js files are added outside allowed legacy paths
- name: Check for new JS files
run: |
NEW_JS=$(git diff --name-only origin/main...HEAD -- '*.js' ':!legacy/**')
if [ -n "$NEW_JS" ]; then
echo "New .js files outside legacy/ are not allowed:"
echo "$NEW_JS"
exit 1
fi// eslint config, enforced in CI
{
"rules": {
"@typescript-eslint/no-explicit-any": "error"
}
}Running tsc --noEmit in CI on every PR (separate from the actual build step) is the other essential check — it catches type errors introduced by a change even in files the PR didn't directly touch, which is exactly the kind of regression an incremental migration is otherwise prone to.
Common mistakes
- Trying to convert the whole codebase in one large pull request. It's slow to review, high-risk to merge, and abandons the entire point of an incremental migration — small, revertible steps.
- Typing everything as
anyjust to make errors disappear.anyopts a value out of type checking entirely, which means the migration adds files without adding real safety — preferunknownplus a narrowing check when a type genuinely isn't known yet. - Enabling full
strictmode on day one on a large legacy codebase. Hundreds of simultaneous errors are demoralizing and hard to review meaningfully — enable the individual strict flags incrementally instead, as described above. - Converting leaf utility files last instead of first. Files with few dependencies are the lowest-risk, fastest wins — starting with the most central, most-imported files first maximizes the chance of getting stuck deep in a conversion with a half-broken build.
Related reading
- TypeScript Utility Types Cheat Sheet — shares tags: typescript, javascript, programming (same category).
- The JavaScript Event Loop, Explained With Diagrams — shares tags: javascript, programming.
- Modern Array Methods You Should Be Using Instead of Loops — shares tags: javascript, programming.
- Async Python with asyncio: A Practical Introduction — shares tags: programming.
- Big O Notation Without the Math Panic — shares tags: programming.