DEV Community

Alex Rogov
Alex Rogov

Posted on • Originally published at alexrogov.hashnode.dev

How We Migrated 200K Lines from JS to Strict TypeScript

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/**/*"]
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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.json with allowJs: true, strict: false
  • [ ] Add tsc --noEmit to 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: strictNullChecksnoImplicitAnystrict
  • [ ] Add CI guard against new .js files
  • [ ] Ban any with 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
  • strictNullChecks is 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)