We didn't mass-rename .js files to .ts and call it a day. We migrated 200,000 lines of production JavaScript to strict TypeScript — with zero downtime, over 4 months, while shipping features every sprint.
Here's the playbook that actually worked.
Why We Almost Didn't Do It
The codebase was 6 years old. A Node.js monolith serving 50K daily active users, built by 12+ developers over the years. Some files had JSDoc types. Most had // @ts-ignore comments from a half-hearted migration attempt in 2021.
The business case was clear: we were spending 30% of every sprint debugging type-related bugs that TypeScript would have caught at compile time. Null reference errors in production. API responses with unexpected shapes. Refactoring was terrifying because nobody knew what would break.
But a big-bang migration was off the table. We couldn't freeze features for 4 months. So we built a system.
The 5-Phase Strategy
Phase 1: Set Up the Dual Runtime (Week 1)
The first step wasn't touching any JavaScript file. It was making TypeScript and JavaScript coexist peacefully.
// tsconfig.json — the migration config
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"strict": false,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
Key decisions:
-
allowJs: true— JS and TS files live together -
strict: false— we'll tighten this incrementally -
checkJs: false— don't type-check existing JS (yet)
We also added a CI step that ran tsc --noEmit on every PR. At this point it checked nothing, but the infrastructure was in place.
Phase 2: Define the Boundary Types (Weeks 2-3)
Before converting any file, we typed the boundaries: API responses, database models, and shared interfaces.
// src/types/api.ts — typed API contracts
interface User {
id: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: Date;
profile: UserProfile | null;
}
interface UserProfile {
displayName: string;
avatarUrl: string | null;
bio: string;
}
interface ApiResponse<T> {
data: T;
meta: {
total: number;
page: number;
perPage: number;
};
}
// src/types/database.ts — Prisma-generated types were our source of truth
// But we created domain interfaces that didn't depend on Prisma
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
create(data: CreateUserInput): Promise<User>;
update(id: string, data: UpdateUserInput): Promise<User>;
}
Why this matters: Once the boundary types existed, every converted file had something to reference. The types flowed inward — from the edges of the system toward the core.
Phase 3: The Module-by-Module Conversion (Weeks 4-14)
This is where the real work happened. We established rules:
Rule 1: Convert leaf modules first.
Start with files that have no internal imports — utilities, helpers, validators. They're self-contained, so you can convert them without touching anything else.
// Before: src/utils/format-date.js
function formatDate(date, format) {
// 47 lines of date formatting
// 3 bug reports in the last year from null dates
}
// After: src/utils/format-date.ts
type DateFormat = 'short' | 'long' | 'iso' | 'relative';
function formatDate(date: Date | string | null, format: DateFormat = 'short'): string {
if (!date) return '—';
const parsed = typeof date === 'string' ? new Date(date) : date;
if (isNaN(parsed.getTime())) return 'Invalid date';
// Same 47 lines, but now with type safety
}
Rule 2: One module per PR. Never mix migration with feature work.
This was non-negotiable. Every migration PR was purely mechanical: rename .js → .ts, add types, fix errors. No refactoring. No improvements. Just types.
Why? Because migration PRs should be boring. The reviewer should be able to approve them in 5 minutes by confirming that behavior didn't change.
Rule 3: Track progress with a migration scorecard.
# migration-stats.sh — we ran this weekly
echo "=== Migration Progress ==="
JS_COUNT=$(find src -name "*.js" -not -path "*/node_modules/*" | wc -l)
TS_COUNT=$(find src -name "*.ts" -o -name "*.tsx" | wc -l)
TOTAL=$((JS_COUNT + TS_COUNT))
PCT=$((TS_COUNT * 100 / TOTAL))
echo "JS files: $JS_COUNT"
echo "TS files: $TS_COUNT"
echo "Progress: $PCT%"
echo ""
echo "=== Strict Mode Violations ==="
npx tsc --noEmit --strict 2>&1 | grep "error TS" | sed 's/(.*//' | sort | uniq -c | sort -rn | head -20
We posted the results in Slack every Friday. Seeing the percentage climb from 12% → 45% → 78% kept the team motivated.
Phase 4: Tighten the Compiler (Weeks 12-16)
Once we hit 80% TypeScript, we started enabling strict flags one by one:
// Week 12: Enable strictNullChecks
{ "strictNullChecks": true }
// → Found 847 errors. Fixed over 2 weeks.
// → Caught 3 actual null-reference bugs in production code.
// Week 14: Enable noImplicitAny
{ "noImplicitAny": true }
// → Found 312 errors. Most were function parameters.
// → Forced us to type every public API.
// Week 15: Enable strictFunctionTypes
{ "strictFunctionTypes": true }
// → Found 56 errors. Mostly event handler signatures.
// Week 16: Full strict mode
{ "strict": true }
// → 23 remaining errors, all edge cases.
The killer insight: strictNullChecks alone found 3 production bugs we didn't know about. One was a race condition where a user's profile could be null during the first 200ms after signup. We'd been silently swallowing the error for months.
Phase 5: Lock the Door (Week 16+)
Once everything was converted and strict mode was on, we added guardrails to prevent regression:
# .github/workflows/typescript-guard.yml
- name: No new JS files
run: |
NEW_JS=$(git diff --name-only origin/main | grep '\.js$' | grep -v '.config.js' | grep -v '.eslintrc.js')
if [ -n "$NEW_JS" ]; then
echo "❌ New .js files detected. All new code must be TypeScript."
echo "$NEW_JS"
exit 1
fi
- name: Strict mode check
run: npx tsc --noEmit --strict
We also added an ESLint rule that banned any:
{
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error"
}
The Numbers
After 4 months:
| Metric | Before | After | Change |
|---|---|---|---|
| Type-related bugs (per sprint) | 4-6 | 0-1 | -85% |
| Time to onboard new developer | 3 weeks | 1.5 weeks | -50% |
| Refactoring confidence (team survey) | 3.2/10 | 8.1/10 | +153% |
| CI pipeline catch rate | ~40% | ~95% | +137% |
| Build time | 12s | 18s | +50% |
Yes, the build got slower. We accepted that trade-off without hesitation.
What I'd Do Differently
1. Start with strictNullChecks from day one. We waited until 80% conversion. In retrospect, enabling it early would have caught bugs sooner and forced better typing habits from the start.
2. Use codemods more aggressively. We manually converted most files. Tools like ts-migrate from Airbnb could have automated 60% of the mechanical work.
3. Type the tests. We left test files as .js until the very end. This meant we had type-safe source code tested by untyped tests — the tests themselves had type bugs.
4. Don't allow as casts without a comment. We ended up with 200+ type assertions that hid real issues. Every as should require a // SAFETY: reason comment explaining why the cast is correct.
The Migration Checklist
If you're planning your own migration, here's the condensed checklist:
- [ ] Set up
tsconfig.jsonwithallowJs: true,strict: false - [ ] Add
tsc --noEmitto CI (even if it catches nothing yet) - [ ] Type the boundaries first: API contracts, DB models, shared interfaces
- [ ] Convert leaf modules (utils, helpers) first
- [ ] One module per PR — never mix migration with features
- [ ] Track progress weekly (file count, error count)
- [ ] Enable strict flags incrementally:
strictNullChecks→noImplicitAny→strict - [ ] Add CI guard against new
.jsfiles - [ ] Ban
anywith ESLint after strict mode is on - [ ] Convert test files last (but do convert them)
Key Takeaways
- Incremental migration beats big-bang every time — we shipped features throughout the 4-month process
- Type the boundaries first — API responses, DB models, and shared interfaces give every file something to reference
-
strictNullChecksis the highest-ROI flag — it found 3 production bugs we didn't know about - One module per PR — keep migration PRs boring and reviewable in 5 minutes
- Track progress visually — a weekly scorecard keeps the team motivated when progress feels slow
I share daily insights on AI-augmented architecture and TypeScript patterns on Twitter/X. Connect with me on LinkedIn — let's talk about your migration.
Originally published on my Hashnode blog. Follow me for more AI + Architecture content.
Top comments (0)