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.

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.
// 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"]
}# 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 typesyncAt 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
// ❌ 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 unchangedPhase 3: Define Core Interfaces
Create type definitions for your domain objects early. These interfaces are used by every file you convert afterward.
// 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.
// 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>[]// ❌ 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.
// ❌ Enabling strict all at once — hundreds of errors
{
"compilerOptions": {
"strict": true // Enables ALL strict checks simultaneously
}
}// ✅ 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
}
}// 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 herestrictNullChecks 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.
#!/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 --detailRun 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
- Start with
allowJs: trueandstrict: false— let TypeScript accept existing JavaScript as-is - Convert leaf modules first — utilities and types before route handlers and entry points
- Define domain interfaces early — shared types accelerate conversion of every subsequent file
- Enable strict checks incrementally —
noImplicitAnyandstrictNullChecksfirst, then the rest - Create declaration files for untyped libraries — minimal types for what you use, not the full API
- Track progress in CI — measure TypeScript file percentage and type coverage to maintain momentum


