Gradual Migration: A Roadmap for 100k-Line JS Projects
Why Big-Bang Rewrites Fail
Many teams look at a legacy JS codebase and want to rewrite it entirely: start a new TypeScript project, convert all files, fix every type error, then ship. This plan almost never works, for these reasons:
Timeline explosion. A 100,000-line codebase with strict mode enabled can produce thousands of type errors. While fixing them, business requirements keep adding features to the old code โ the two tracks never converge.
Merge hell. Large-scale rewrites sit on a long-lived branch. The diff against main grows continuously, and merge cost scales exponentially.
Missing regression coverage. Behavioral regressions during a rewrite are hard to spot, especially in codebases without adequate test coverage.
Team burnout. After a "TypeScript migration" branch has been open for three months, everyone stops caring.
The correct approach is gradual migration: JS and TS files coexist in the same codebase, TypeScript coverage expands incrementally, and every step is a small, independently shippable increment.
Migration Roadmap Overview
Phase 1: Zero-friction start (1-2 weeks)
โ allowJs + checkJs: type checking without renaming files
โ JSDoc: add type annotations to JS files
Phase 2: Strategic file renaming (ongoing)
โ Start from leaf modules, rename to .ts
โ @ts-nocheck to defer hard files
Phase 3: Tighten types (1-3 months)
โ Enable strict sub-options one at a time
โ Replace any at boundaries with unknown
Phase 4: Eliminate remaining any (ongoing)
โ noImplicitAny: true as the final milestone
โ Track progress with type-coverage
Phase 1: Zero-Friction Start
allowJs: true + checkJs: true
These two options let TypeScript check .js files without renaming them โ the lowest-friction entry point for migration.
// tsconfig.json (initial migration config)
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noImplicitAny": false, // turn off โ JS files are full of implicit any
"strict": false, // don't enable strict yet
"outDir": "./dist",
"target": "ES2020",
"module": "CommonJS"
},
"include": ["src/**/*"]
}
With this enabled, TypeScript immediately flags many low-level errors in JS files without requiring any file renames:
// src/utils/format.js (still a .js file)
// TypeScript infers value as number from usage context
function formatPrice(value) {
return "$" + value.toFixed(2); // warns if value might be a string
}
// Passing the wrong type at callsites produces type hints
formatPrice("hello"); // TypeScript can detect this is a string
JSDoc Annotations: Types Inside JS
Without renaming files, add types to JS code via JSDoc comments. TypeScript reads and uses these annotations.
// src/api/users.js
/**
* @typedef {Object} User
* @property {string} id
* @property {string} name
* @property {string} email
* @property {"admin" | "user" | "guest"} role
*/
/**
* Fetch a user by ID
* @param {string} userId
* @returns {Promise<User>}
*/
async function getUserById(userId) {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
/**
* Update user fields
* @param {string} userId
* @param {Partial<User>} updates
* @returns {Promise<User>}
*/
async function updateUser(userId, updates) {
const response = await fetch(`/api/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
return response.json();
}
JSDoc's advantage: adds type information to JS files without requiring a TypeScript toolchain. Existing JS tools can still run the files directly. It's an effective bridging phase.
Handling the Initial Error Flood
When you first enable checkJs, you may see a large number of noise errors. Use // @ts-nocheck to temporarily skip entire files, and only deal with the files you can handle now:
// src/legacy/old-module.js
// @ts-nocheck
// TODO: address type issues when migrating to TypeScript
// All type errors in this file are ignored
Use // @ts-ignore to skip individual lines:
// Type error on next line is known, will fix during migration
// @ts-ignore
const result = weirdLegacyFunction(data);
Phase 2: Strategic File Renaming
Start from Leaf Modules
Leaf modules are files that don't import anything from inside the project โ they only depend on node_modules. They sit at the bottom of the dependency tree.
Project dependency graph (simplified):
app.js
โโโ api/router.js
โ โโโ api/users.js โ leaf (only uses node_modules)
โ โโโ api/products.js โ leaf
โโโ utils/format.js โ leaf
โโโ utils/logger.js โ leaf
Correct migration order: rename leaf nodes (format.js, logger.js, users.js) to .ts first, then work up to modules that depend on them.
Why start at leaves? Once a leaf module becomes .ts, every module that imports it immediately gains type information. Starting at top-level modules gives you the least benefit: you'd still be depending on untyped modules below.
Finding leaf modules:
# Find files with no imports from within src/ (rough approach)
# Use tools like dependency-cruiser for precise analysis
npx depcruise --include-only "^src" --output-type dot src | dot -T svg > deps.svg
Steps When Renaming a File
Using src/utils/format.js as an example:
# 1. Rename the file
mv src/utils/format.js src/utils/format.ts
# 2. Check type errors immediately
npx tsc --noEmit 2>&1 | grep "format.ts"
Handling type errors after renaming:
// src/utils/format.ts (after rename)
// Original JS code
function formatPrice(value) { // Error: implicit any
return "$" + value.toFixed(2);
}
// Add type annotations
function formatPrice(value: number): string {
return "$" + value.toFixed(2);
}
// For complex functions, use any as a bridge โ refine in Phase 3
function complexLegacyFn(data: any): any { // defer to Phase 3
// ... complex logic
}
// @ts-nocheck for Deferring Hard Files
Some files have too many type errors to fix quickly without disrupting delivery. Rename them (to get IDE support), then add @ts-nocheck to defer the fix:
// src/legacy/complex-module.ts
// @ts-nocheck
// TODO: type issues to be addressed โ estimated effort: 2 days
// Added: 2024-01-15
// File contents unchanged; TypeScript won't check this file
Track every @ts-nocheck file in your team's issue system to prevent them from becoming permanent.
Phase 3: Tighten Types
Once most files are .ts, start enabling strict options one at a time. Enable one, fix all errors, then enable the next.
Recommended Enablement Order
// Step 3.1: The two safest ones first
{
"compilerOptions": {
"strictNullChecks": true, // start here โ highest ROI
"noImplicitAny": true // then this
}
}
// Step 3.2: Next batch
{
"compilerOptions": {
"strictFunctionTypes": true,
"strictBindCallApply": true
}
}
// Step 3.3: Remaining options
{
"compilerOptions": {
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"useUnknownInCatchVariables": true
}
}
// Final goal:
{
"compilerOptions": {
"strict": true
}
}
Handling the Error Flood from strictNullChecks
The most common error patterns and fixes after enabling strictNullChecks:
// Pattern 1: function parameter may be null/undefined
function processUser(user: User) { // original code
return user.name.toUpperCase();
}
// Callers that pass null now cause errors
// Fix: allow null in the type and handle it inside
function processUser(user: User | null): string {
if (!user) return "";
return user.name.toUpperCase();
}
// Pattern 2: DOM operations return null
const button = document.getElementById("submit"); // type: HTMLElement | null
button.addEventListener("click", handler); // Error: button may be null
// Fix
const button = document.getElementById("submit");
if (button) {
button.addEventListener("click", handler);
}
// Or use non-null assertion when you're certain the element exists
const button = document.getElementById("submit")!;
button.addEventListener("click", handler);
// Pattern 3: Object properties may be undefined
interface Config {
timeout?: number;
}
function applyConfig(config: Config) {
const timeout = config.timeout * 1000; // Error: timeout may be undefined
// Fix
const timeout = (config.timeout ?? 30) * 1000;
}
Replacing Boundary any with unknown
Find all the any values that serve as entry points (API responses, user input, external config) and replace them with unknown, forcing a type narrowing before use:
// Before: any propagates everywhere
async function fetchData(url: string): Promise<any> {
const res = await fetch(url);
return res.json(); // returns any, contaminates the call chain
}
const data = await fetchData("/api/users");
data.users.forEach((u: any) => console.log(u.name)); // any chain
// After: unknown at the boundary
async function fetchData(url: string): Promise<unknown> {
const res = await fetch(url);
return res.json();
}
// Callers must validate types before use
const data = await fetchData("/api/users");
const parsed = UserListSchema.parse(data); // validate with zod
parsed.users.forEach(u => console.log(u.name)); // type-safe
@ts-expect-error vs @ts-ignore: Always Prefer the Former
// @ts-ignore: suppresses errors on the next line regardless of whether an error exists
// @ts-ignore
const result = brokenFunction(); // if brokenFunction is later fixed, this becomes noise
// @ts-expect-error: the next line MUST have a type error, otherwise TypeScript reports an error
// @ts-expect-error: third-party library type bug โ check after upgrading
const result = brokenFunction();
// When brokenFunction's types are fixed, TypeScript warns: "This @ts-expect-error is no longer needed"
// This lets you track which suppressions have become stale
During migration, use @ts-expect-error to mark known temporary workarounds, with comments explaining the reason and planned fix date.
Phase 4: Eliminate Remaining any
Tracking Progress with type-coverage
# Install
npm install -D type-coverage
# Check current coverage
npx type-coverage
# Detailed report (lists every any location)
npx type-coverage --detail
# Set a minimum threshold (for CI)
npx type-coverage --atLeast 90
Sample output:
9312 / 9704 97.44%
This means 9312 of 9704 type nodes have explicit types (not any).
Adding type-coverage to CI
# .github/workflows/ci.yml
- name: Check type coverage
run: npx type-coverage --atLeast 95
Set an incrementally rising target: 80% in month one, 85% in month two, aiming for 95%+. No PR should decrease coverage.
Systematically Eliminating any
Identify the sources of any and handle them by category:
// Source 1: function parameters with no type annotation (noImplicitAny catches most)
function process(data: any) { /* ... */ } // replace with a specific type
// Source 2: JSON.parse returns any (accept with unknown)
const data: unknown = JSON.parse(text);
// Source 3: third-party library with no types (install @types/xxx or write a .d.ts)
const result = legacyLib.doSomething(); // returns any
// Source 4: explicit any escaping complex types (replace with generics or precise types)
function identity(x: any): any { return x; }
function identity<T>(x: T): T { return x; } // use a generic
// Source 5: type assertions using as any (most dangerous โ prioritize these)
const user = data as any; // why was this added? Delete it and see the real error
Quantifiable Migration Metrics
Track these metrics throughout migration to make progress visible:
# 1. TS file ratio (how many files are already .ts)
ls src/**/*.ts | wc -l
ls src/**/*.js | wc -l
# 2. Type coverage (any ratio)
npx type-coverage
# 3. Number of @ts-nocheck files
grep -rl "@ts-nocheck" src/ | wc -l
# 4. Number of @ts-ignore and @ts-expect-error suppressions
grep -r "@ts-ignore\|@ts-expect-error" src/ | wc -l
# 5. Compile error count (target: 0)
npx tsc --noEmit 2>&1 | grep "error TS" | wc -l
Record these numbers weekly in team documentation or a Slack channel to maintain migration momentum.
Common Pitfalls and How to Avoid Them
Pitfall 1: Mixing migration and feature development in one PR
Wrong:
- One PR: rename format.js to format.ts + add new feature + fix a bug
Correct:
- Type migration PR: only rename files and add type annotations, no behavior change
- Feature PR: normal development, entirely in .ts files
Mixed PRs are hard to review, and when something breaks it's impossible to tell if the migration or the feature caused it.
Pitfall 2: Refactoring while migrating
// Wrong: treating migration as an opportunity to rewrite logic simultaneously
// Tests become invalid, behavioral consistency can't be verified
// Correct: in a migration PR, only add types โ don't change logic
// This ensures that fixing any doesn't introduce new bugs
Pitfall 3: Using as assertions to "fix" all any instead of truly fixing them
// Quick and wrong: assert all any away
const user = getUser() as User; // if getUser truly returns any, this is still risky
// Correct: find the source of any and fix it at the root
// Change getUser's return type from any to Promise<User>,
// and validate internally with a type guard or zod
Pitfall 4: Ignoring any coming from third-party libraries
// Express's req.body is any โ leaving it alone pollutes everything
app.post("/users", (req, res) => {
const { name, email } = req.body; // any leaks in
createUser(name, email); // no type protection
});
// Validate at the entry point with zod
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
app.post("/users", (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) return res.status(400).json({ errors: result.error.format() });
const { name, email } = result.data; // type-safe
createUser(name, email);
});
Real Migration Timeline Reference
Estimates based on actual project experience (100,000-line JS codebase, 5โ10 person team):
| Phase | Duration | Key Milestone |
|---|---|---|
| Phase 1: allowJs + checkJs | 1โ2 weeks | Baseline error count established, JSDoc coverage started |
| Phase 2: file renaming (50%) | 4โ8 weeks | 50% of files are .ts, type-coverage > 60% |
| Phase 2: file renaming (100%) | 4โ8 weeks | All files are .ts, type-coverage > 70% |
| Phase 3: strict options one by one | 4โ12 weeks | strict: true enabled, type-coverage > 85% |
| Phase 4: eliminate any | Ongoing | type-coverage > 95%, zero @ts-nocheck files |
Note: these estimates assume migration is part of regular team work, not a dedicated halt of all business development. Actual pace depends on codebase complexity and team allocation.
Anti-Patterns
Anti-pattern: Pursuing perfect types immediately instead of incrementally
// Wrong mindset:
// "The types on this function must be perfect before I commit"
// Result: migration PRs grow huge and become impossible to merge
// Correct mindset:
// "Get the file to .ts with basic types passing, use @ts-expect-error for temporary issues"
// Then refine types in follow-up PRs
// A .ts file with 3 @ts-expect-error suppressions
// is far better than a .js file with no types at all
Anti-pattern: Skipping Phase 1 and renaming files directly
If the codebase has 500 JS files and you rename all of them to .ts at once without building the infrastructure first, you face thousands of errors simultaneously. Teams give up immediately.
Anti-pattern: Not including migration metrics in sprints
Wrong:
- Migration is a "do it when we have time" activity
- Result: permanently stuck at 30% coverage
Correct:
- Allocate a fixed percentage (e.g., 20%) of each sprint to type migration
- Write type-coverage targets into sprint goals
Summary Table
| Phase | Key Options | Quantifiable Metric |
|---|---|---|
| Phase 1 | allowJs, checkJs, noImplicitAny: false |
Baseline error count |
| Phase 2 | File renaming, @ts-nocheck for deferrals |
.ts file percentage |
| Phase 3 | strictNullChecks, noImplicitAny, etc. enabled one by one |
type-coverage percentage |
| Phase 4 | noImplicitAny: true, clearing @ts-nocheck |
any node count, @ts-nocheck file count |
Closing Note
TypeScript's value doesn't come from a one-time big migration โ it accumulates through small increments: every function where any becomes a concrete type, every potential null pointer caught by strictNullChecks, every call site error prevented by type inference. The core philosophy of gradual migration is to make the codebase a little better than yesterday, rather than waiting for the "perfect moment" to act.
Further Reading
- TypeScript Official Migration Guide
- type-coverage CLI tool
- DefinitelyTyped โ where to go when third-party libraries have no types