Chapter 19

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.

// 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

Rate this chapter
4.6  / 5  (10 ratings)

๐Ÿ’ฌ Comments