Skip to content

Migrating from JavaScript to TypeScript Incrementally

A step-by-step guide to migrating JavaScript projects to TypeScript without pausing feature work: configuration, strict mode adoption and team workflows.

4 min read
Migration timeline showing JavaScript files progressively converting to TypeScript with strict mode stages

Migrating a JavaScript codebase to TypeScript does not require a big-bang rewrite. The most successful migrations happen incrementally — one file at a time, with each step adding type safety while keeping the application functional. You can ship features during the migration, not after it.

The key insight: TypeScript is a superset of JavaScript. Any .js file is valid TypeScript with minimal changes. Start permissive, then tighten strictness as coverage improves.

Phase 1: Configuration Without Code Changes

Before touching any JavaScript file, set up TypeScript to accept your existing codebase as-is.

jsonjson
// tsconfig.json — permissive starting point
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "allowJs": true,          // Allow .js files alongside .ts
    "checkJs": false,         // Don't type-check .js files yet
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": false,          // Start permissive
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
shbash
# Install TypeScript and type definitions for your dependencies
npm install -D typescript @types/node @types/react @types/express
# Check which @types packages exist for your dependencies
npx typesync

At this point, nothing changes for existing code. JavaScript files compile through TypeScript unchanged. The build works. Tests pass. Deploy it.

Phase 2: Rename Files Strategically

Start renaming .js files to .ts (or .jsx to .tsx) beginning with leaf modules — files that import other files but are not imported by many themselves.

## Migration order (leaf → root):

1. Utility functions (src/utils/*.js → *.ts)
   - Pure functions, few dependencies, easy to type

2. Data types and constants (src/types/*.js → *.ts)
   - Define interfaces that other files will use

3. Service layers (src/services/*.js → *.ts)
   - Database queries, API clients

4. Business logic (src/domain/*.js → *.ts)
   - Core application logic

5. Route handlers / components (src/routes/*.js → *.ts)
   - Entry points that compose everything

6. Entry point (src/index.js → src/index.ts)
   - Last file to convert
tstypescript
// ❌ Converting a file and adding full types immediately
// This blocks the migration because you need types for everything
 
// ✅ Rename and add minimal types — fix the rest incrementally
// Before: src/utils/format.js
export function formatCurrency(amount) {
  return `$${amount.toFixed(2)}`;
}
 
// After: src/utils/format.ts
export function formatCurrency(amount: number): string {
  return `$${amount.toFixed(2)}`;
}
// Quick win — function signature typed, behavior unchanged

Phase 3: Define Core Interfaces

Create type definitions for your domain objects early. These interfaces are used by every file you convert afterward.

tstypescript
// src/types/domain.ts
export interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user' | 'moderator';
  createdAt: Date;
  updatedAt: Date;
}
 
export interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  inStock: boolean;
}
 
export interface Order {
  id: string;
  userId: string;
  items: OrderItem[];
  total: number;
  status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
  createdAt: Date;
}
 
export interface OrderItem {
  productId: string;
  quantity: number;
  unitPrice: number;
}
 
// API response wrapper
export interface ApiResponse<T> {
  data: T;
  meta?: {
    page: number;
    pageSize: number;
    total: number;
  };
}
 
export interface ApiError {
  error: string;
  status: number;
  details?: Record<string, string[]>;
}

Phase 4: Handle Third-Party Libraries

Some libraries have @types packages. Some have built-in types. Some have neither. Handle each case differently.

tstypescript
// Case 1: Library has @types package — just install it
// npm install -D @types/lodash
import { groupBy } from 'lodash';
// Types work automatically
 
// Case 2: Library has built-in types — nothing to install
import axios from 'axios';
// axios ships its own .d.ts files
 
// Case 3: No types available — create a declaration file
// src/types/untyped-lib.d.ts
declare module 'legacy-csv-parser' {
  interface ParseOptions {
    delimiter?: string;
    headers?: boolean;
  }
 
  interface ParseResult {
    data: Record<string, string>[];
    errors: string[];
  }
 
  export function parse(input: string, options?: ParseOptions): ParseResult;
}
 
// Now you can import with types:
import { parse } from 'legacy-csv-parser';
const result = parse(csvData, { delimiter: ',' });
// result.data is Record<string, string>[]
tstypescript
// ❌ Using 'any' to silence type errors on untyped libraries
const result: any = legacyLib.doSomething();
// Loses all type safety downstream
 
// ✅ Create a minimal declaration and improve it over time
// Start with what you actually use, not the full API surface
declare module 'legacy-lib' {
  export function doSomething(): { id: string; value: number };
}

Phase 5: Incremental Strict Mode

TypeScript's strict flag is actually a bundle of individual checks. Enable them one at a time instead of all at once.

jsonjson
// ❌ Enabling strict all at once — hundreds of errors
{
  "compilerOptions": {
    "strict": true  // Enables ALL strict checks simultaneously
  }
}
jsonjson
// ✅ Enable individual strict checks progressively
{
  "compilerOptions": {
    "strict": false,
 
    // Phase 1: Enable these first (easiest to fix)
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true,
 
    // Phase 2: Enable after Phase 1 is clean
    "noImplicitAny": true,           // Most impactful
    "strictNullChecks": true,         // Catches real bugs
 
    // Phase 3: Enable last (most strict)
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true
  }
}
tstypescript
// noImplicitAny — before:
function processItem(item) {    // 'item' implicitly has 'any' type
  return item.name.toUpperCase();
}
 
// noImplicitAny — after:
function processItem(item: Product): string {
  return item.name.toUpperCase();
}
 
// strictNullChecks — before:
function getUser(id: string): User {
  return users.find(u => u.id === id);  // Could be undefined!
}
 
// strictNullChecks — after:
function getUser(id: string): User | undefined {
  return users.find(u => u.id === id);
}
 
// Callers must handle the undefined case:
const user = getUser('123');
if (!user) {
  throw new Error('User not found');
}
console.log(user.name); // TypeScript knows user is not undefined here

strictNullChecks is the single most valuable flag. It catches null/undefined errors at compile time that would otherwise be runtime crashes.

Tracking Migration Progress

Measure progress so the team stays motivated and management understands the investment.

shbash
#!/bin/bash
# scripts/migration-progress.sh
 
TOTAL_JS=$(find src -name "*.js" -o -name "*.jsx" | wc -l)
TOTAL_TS=$(find src -name "*.ts" -o -name "*.tsx" | wc -l)
TOTAL=$((TOTAL_JS + TOTAL_TS))
 
if [ $TOTAL -gt 0 ]; then
  PERCENT=$((TOTAL_TS * 100 / TOTAL))
else
  PERCENT=0
fi
 
echo "Migration Progress:"
echo "  JavaScript files: $TOTAL_JS"
echo "  TypeScript files: $TOTAL_TS"
echo "  Total: $TOTAL"
echo "  Progress: ${PERCENT}%"
echo ""
echo "Type coverage:"
npx type-coverage --detail

Run this in CI and track the percentage over time. A steadily increasing line is motivating. A flat line means the migration stalled and needs attention.

Key Takeaways

  1. Start with allowJs: true and strict: false — let TypeScript accept existing JavaScript as-is
  2. Convert leaf modules first — utilities and types before route handlers and entry points
  3. Define domain interfaces early — shared types accelerate conversion of every subsequent file
  4. Enable strict checks incrementally — noImplicitAny and strictNullChecks first, then the rest
  5. Create declaration files for untyped libraries — minimal types for what you use, not the full API
  6. Track progress in CI — measure TypeScript file percentage and type coverage to maintain momentum
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX