Migrating a Large JavaScript Codebase to TypeScript Without Stopping Feature Work
TL;DR — Don’t do a big-bang rewrite / Turn on
allowJs+checkJsand convert file by file from leaf modules inward / Land strict mode in stages:noImplicitAny→strictNullChecks→ fullstrict: trueover months, not weeks.
I’ve now led two TypeScript migrations end-to-end, one on a ~120k-line Node service and one on a smaller ~40k-line monorepo. Both shipped to strict mode while the team kept shipping product. The thing nobody warns you about: the migration is mostly a project-management problem, not a technical one. The technical steps are well-trodden. Convincing 12 engineers to keep momentum across 4 sprints while they’re also closing tickets — that’s the hard part.
This post is the playbook I’d hand a new tech lead on day one. I’m going to skip the “why TS” framing — I covered that already — and get straight to the mechanics that actually worked.
Decide the destination before the route
Before any code changes, write down what “done” means. The trap is migrating halfway and stalling. “Halfway” is worse than starting because you carry two mental models in your head — what’s typed, what’s not — and the ergonomics get worse, not better.
For a backend Node service in 2023, the destination I’d write down looks like:
strict: trueintsconfig.json(noImplicitAny,strictNullChecks, all the sub-flags)noUncheckedIndexedAccess: true— non-negotiable for backend code- Zero
.jsfiles insrc/ - Zero
anyoutside explicitunknown→ narrowed paths - No
// @ts-ignoreor// @ts-expect-errorwithout a linked issue - A CI gate that fails on type errors, on new
any, and on new.jsfiles insrc/
Now you have an artifact your team can argue with. Lock it before week 1.
Step 1: Set up the runway
The first PR doesn’t convert any files. It makes conversion possible.
npm install -D typescript@4.9 @types/node@18 tsx
npx tsc --init
Then edit the generated tsconfig.json to start permissive:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"allowJs": true,
"checkJs": false,
"outDir": "dist",
"rootDir": "src",
"strict": false,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
allowJs: true lets TS files import JS files (and vice versa). checkJs: false keeps the existing JS un-type-checked so CI doesn’t catch fire on day one. strict: false is on temporarily — we’ll lift it.
Wire up dual execution: tsx for development, tsc for CI type-checking, your bundler of choice (esbuild or swc) for the production build.
// package.json scripts
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --noEmit && esbuild src/index.ts --bundle --platform=node --outfile=dist/index.js",
"typecheck": "tsc --noEmit"
}
}
Add npm run typecheck to CI. Right now it passes trivially because there are no TS files. That’s fine. The job exists.
Step 2: Convert leaves first, not entry points
The instinct is to start with src/index.ts. Don’t. Index files import everything; you’ll end up needing types for the entire graph before the first file compiles cleanly.
Start at the leaves — utility functions, pure logic modules, files with the fewest imports. For each conversion:
- Rename
.jsto.ts - Fix the type errors that pop up
- Export types alongside values
- Run the test suite
Treat every conversion as a small PR. One or two files per PR. Easy to review, easy to revert. The temptation to do mega-PRs converting 30 files at once is real. Resist it. Mega-PRs sit unreviewed and rot.
Order of attack:
- Pure functions (
utils/,lib/) - Type definitions you wish you had (domain models, DTOs)
- Database access layer
- Service layer
- HTTP handlers and middleware
- Entry points
By the time you’re at step 6, the rest of the code is typed and index.ts mostly converts itself.
Step 3: Use JSDoc as a bridge
For modules you can’t convert yet — maybe they’re under active feature work, maybe a senior dev is wary — JSDoc gives you 60% of the benefit with 10% of the friction.
// src/legacy/billing.js — still JS, but typed via JSDoc
/**
* @typedef {Object} Invoice
* @property {string} id
* @property {number} amountCents
* @property {Date} dueAt
* @property {"pending" | "paid" | "void"} status
*/
/**
* @param {Invoice} invoice
* @returns {boolean}
*/
function isOverdue(invoice) {
return invoice.status === "pending" && invoice.dueAt < new Date();
}
With checkJs: true set on this file (you can scope it via a JSDoc // @ts-check directive at the top), TypeScript will type-check the JS file. Errors surface in your editor. The file is still JS — no build pipeline changes needed — but TS treats it as if it were typed.
This is the trick that lets you ship features in legacy modules during the migration without holding up the work. JSDoc-typed modules become natural conversion candidates later — most of the types are already written.
Step 4: Ratchet strictness file by file
Once strict: false files compile, start ratcheting. The standard way is a per-file // @ts-strict directive plus a custom ESLint rule, or — simpler — use @typescript-eslint’s ban-ts-comment plus a strictness ratchet mechanism. I prefer this manual approach:
- Globally set
strict: false,noImplicitAny: true. Force at least implicit-any errors to be caught. - Once the codebase passes
noImplicitAny, flipstrictNullChecks: true. This is the painful one — it forces everyT | undefinedandT | nullto be handled. Expect 4-8 weeks of cleanup on a 100k LOC codebase. - Once null-checks pass, flip remaining strict flags one at a time.
Track progress with a simple CI script:
# Count remaining JS files in src/
find src -name "*.js" | wc -l
# Count any usages
grep -rE "\b: any\b|\bas any\b" src --include="*.ts" | wc -l
Post these counts to Slack weekly. Visible progress matters. Engineers convert files faster when the number is going down on a dashboard.
Step 5: Hold the line in CI
The migration fails when typed files start sliding back. Add CI gates from the moment you have a single .ts file.
# .github/workflows/ci.yml — relevant job
- name: typecheck
run: npm run typecheck
- name: no new any
run: |
NEW_ANY=$(git diff origin/main...HEAD --unified=0 -- '*.ts' \
| grep -E '^\+.*: any\b|^\+.*as any\b' \
| wc -l)
if [ "$NEW_ANY" -gt 0 ]; then
echo "PR introduces $NEW_ANY new uses of 'any'. Use 'unknown' or a real type."
exit 1
fi
- name: no new js in src
run: |
NEW_JS=$(git diff --name-status origin/main...HEAD \
| awk '$1=="A" && $2 ~ /^src\/.*\.js$/' \
| wc -l)
if [ "$NEW_JS" -gt 0 ]; then
echo "PR adds new .js files in src/. Use .ts instead."
exit 1
fi
These gates prevent regression. The migration ratchets only forward.
How long does this actually take
For a 100k-line codebase with a team of 4-6 engineers doing it as a background workstream (not full-time):
- Week 1: runway PR, tooling, CI gates, first 5-10 conversions
- Weeks 2-6:
noImplicitAnyclean, ~40-50% of files converted - Weeks 7-12:
strictNullChecksclean, 80%+ converted - Weeks 13-16: all strict flags, last legacy modules, cleanup
That’s roughly four months. You can compress to 8 weeks by giving one engineer 50% time on it as a primary responsibility. You can’t realistically compress further on a real codebase without freezing features.
Common Pitfalls
The migrations that stall usually hit one of these:
- Skipping the destination spec. Without a written “done,” the migration becomes a moving target and stalls at 60%.
- Mega-conversion PRs. A 40-file conversion PR will sit unreviewed for a week. Convert in small batches.
- Premature strict mode. Turning on
strict: truefrom day one floods you with thousands of errors. Demoralizing. Ratchet up. as anyas the escape valve. Every time someone hits a hard error and writesas any, the codebase regresses. Ban it in CI.- Migrating tests last. Test files have the trickiest types (mocks, spies, deeply-nested fixtures). Tackle them mid-migration, not at the end, or you’ll discover late that your testing patterns don’t survive strict types.
- Forgetting to update
@types/*. Stale type packages cause weird errors. After major upgrades, audit@types/node,@types/express, etc.
Wrapping Up
The migration is a marathon, not a heroic weekend. The teams that finish are the ones with visible CI gates, weekly progress numbers, and a destination spec everyone agreed to up front. Next post in this series will be on the satisfies operator, which solves a class of “do I cast or annotate” problems that come up constantly during a migration.
If you start one of these, send me a note in three months. I want to know how it went.