Difficulty: Intermediate
How would you migrate a large JavaScript codebase to TypeScript? What is the recommended strategy?
Migrating a large JS codebase to TypeScript should be done incrementally, not all at once. The recommended strategy:
1. Setup phase: Add tsconfig with allowJs: true and checkJs: false. Both .js and .ts files coexist. 2. Infrastructure first: Migrate config files, build scripts, and utilities first. 3. Strict at the boundary: Enable strict for new .ts files, allow loose typing for existing .js files. 4. Bottom-up migration: Start with leaf modules (no dependencies), then work up to modules with more dependencies. 5. Type coverage tracking: Gradually increase coverage, aiming for 100% strict over time.
Key enablers: allowJs, checkJs, JSDoc type annotations, @ts-ignore/@ts-expect-error for temporary suppressions.
// Phase 1: Initial tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
// Migration enablers
"allowJs": true, // Allow .js files in project
"checkJs": false, // Don't type-check .js files yet
"strict": false, // Relaxed for migration
"noImplicitAny": false, // Allow implicit any during migration
"skipLibCheck": true,
"outDir": "./dist",
"declaration": true
},
"include": ["src//*"]
}
// Phase 2: Enable checking gradually
// "checkJs": true // Type-check JS files using JSDoc
// "noImplicitAny": true // After most files are typed
// Phase 3: Full strict mode
// "strict": true
// "allowJs": false // Remove when all .js files are converted
Start with allowJs: true and strict: false. This lets .js and .ts files coexist. Gradually enable strict options as more files are converted.
// utils.js - Add types via JSDoc without renaming to .ts
/
* @typedef {Object} User
* @property {number} id
* @property {string} name
* @property {string} email
* @property {boolean} [isActive] - optional property
*/
/
* Fetch a user by ID
* @param {number} id - The user ID
* @returns {Promise<User>} The user object
*/
async function getUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
/
* @template T
* @param {T[]} items
* @param {(item: T) => boolean} predicate
* @returns {T | undefined}
*/
function findItem(items, predicate) {
return items.find(predicate);
}
// With checkJs: true, TypeScript validates these JSDoc types
// This lets you add type safety without renaming files
JSDoc annotations provide type safety in .js files without renaming. With checkJs: true, TypeScript validates JSDoc types. This is perfect for gradual migration.
// Step 1: Create shared types file
// src/types.ts
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
export interface ApiResponse<T> {
data: T;
error?: string;
status: number;
}
// Step 2: Rename leaf module .js -> .ts and add types
// src/utils/format.ts (was format.js)
export function formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
export function formatCurrency(amount: number, currency: string = 'USD'): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}
// Step 3: Use @ts-expect-error for known issues (tracked for fix)
function legacyFunction(data: any) {
// @ts-expect-error - TODO: Fix when user module is migrated
return data.process();
}
// Step 4: Track migration progress
// npx type-coverage // Shows percentage of typed code
// npx type-coverage --detail // Shows untyped locations
// Migration checklist:
// [x] src/utils/format.ts (100% typed)
// [x] src/utils/validation.ts (100% typed)
// [ ] src/services/user.js (JSDoc only)
// [ ] src/services/payment.js (untyped)
// [ ] src/routes/api.js (untyped)
Migrate bottom-up: utilities first, then services, then routes. Use @ts-expect-error (not @ts-ignore) for known issues so TypeScript errors when the underlying issue is fixed.
Migration Strategy, allowJs, Gradual Adoption, Type Coverage, JSDoc Types