TypeScript

Migrating JavaScript to TypeScript

40 min Lesson 29 of 40

Introduction to Migration Strategies

Migrating an existing JavaScript codebase to TypeScript doesn't have to be an all-or-nothing process. TypeScript offers several features that enable incremental adoption, allowing you to gradually add type safety to your project while maintaining full functionality.

Phase 1: Project Setup

Start by initializing TypeScript in your project:

# Install TypeScript npm install --save-dev typescript # Initialize tsconfig.json npx tsc --init # Install type definitions for your dependencies npm install --save-dev @types/node @types/react @types/lodash

Initial tsconfig.json for Migration

Configure TypeScript to allow JavaScript files and enable gradual migration:

{ "compilerOptions": { // Essential settings for migration "allowJs": true, // Allow JavaScript files "checkJs": false, // Don't type-check JS files (yet) "noEmit": true, // Don't emit during migration "esModuleInterop": true, "skipLibCheck": true, // Target and module settings "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", // Incremental migration "strict": false, // Start lenient, enable later "noImplicitAny": false, // Allow implicit any during migration // Source maps for debugging "sourceMap": true, // Output directory (when you start emitting) "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] }
Note: Start with "strict": false to avoid being overwhelmed with errors. Enable strict mode gradually as you convert files.

Phase 2: Rename Files Incrementally

Start converting files from .js to .ts (or .jsx to .tsx):

Strategy 1: Bottom-Up (Recommended)

Start with utility files and pure functions that have no dependencies:

// Before: utils.js export function formatDate(date) { return date.toISOString().split('T')[0]; } export function calculateTotal(items) { return items.reduce((sum, item) => sum + item.price, 0); } // After: utils.ts export function formatDate(date: Date): string { return date.toISOString().split('T')[0]; } interface Item { price: number; } export function calculateTotal(items: Item[]): number { return items.reduce((sum, item) => sum + item.price, 0); }

Strategy 2: Top-Down

Start with entry points and work down the dependency tree:

// 1. Convert main.js → main.ts // 2. Fix errors in imports // 3. Convert imported files one by one // 4. Repeat until all files are converted

Handling Common Migration Patterns

1. Converting Loose Objects to Interfaces:

// Before: JavaScript const user = { id: 1, name: 'John Doe', email: 'john@example.com' }; function updateUser(user, updates) { return { ...user, ...updates }; } // After: TypeScript interface User { id: number; name: string; email: string; } function updateUser(user: User, updates: Partial<User>): User { return { ...user, ...updates }; }

2. Converting Callbacks to Typed Functions:

// Before: JavaScript function fetchData(url, onSuccess, onError) { fetch(url) .then(response => response.json()) .then(onSuccess) .catch(onError); } // After: TypeScript type SuccessCallback<T> = (data: T) => void; type ErrorCallback = (error: Error) => void; function fetchData<T>( url: string, onSuccess: SuccessCallback<T>, onError: ErrorCallback ): void { fetch(url) .then(response => response.json()) .then(onSuccess) .catch(onError); }

3. Converting Classes:

// Before: JavaScript class UserService { constructor(apiUrl) { this.apiUrl = apiUrl; this.cache = new Map(); } async getUser(id) { if (this.cache.has(id)) { return this.cache.get(id); } const response = await fetch(`${this.apiUrl}/users/${id}`); const user = await response.json(); this.cache.set(id, user); return user; } } // After: TypeScript interface User { id: number; name: string; email: string; } class UserService { private cache: Map<number, User> = new Map(); constructor(private apiUrl: string) {} async getUser(id: number): Promise<User> { const cached = this.cache.get(id); if (cached) { return cached; } const response = await fetch(`${this.apiUrl}/users/${id}`); const user = await response.json() as User; this.cache.set(id, user); return user; } }

Using @ts-check for Gradual Type Checking

Add type checking to JavaScript files without converting them:

// @ts-check /** * @param {number} a * @param {number} b * @returns {number} */ function add(a, b) { return a + b; } add(1, 2); // ✓ OK add(1, '2'); // ✗ Error: Argument of type 'string' is not assignable to parameter

Use JSDoc for complex types:

// @ts-check /** * @typedef {Object} User * @property {number} id * @property {string} name * @property {string} email */ /** * @param {User[]} users * @returns {User[]} */ function sortUsersByName(users) { return users.sort((a, b) => a.name.localeCompare(b.name)); }

Creating Type Definition Files

For JavaScript files you can't convert yet, create .d.ts declaration files:

// legacy-api.js (can't convert yet) export function legacyFunction(data) { // Complex implementation return processData(data); } // legacy-api.d.ts (type definitions) export interface LegacyData { id: number; value: string; } export function legacyFunction(data: LegacyData): string;

Handling Third-Party Libraries

Install type definitions for libraries without built-in types:

# Search for type definitions npm search @types/library-name # Install type definitions npm install --save-dev @types/library-name # If no types exist, create your own # Create: src/types/library-name.d.ts declare module 'library-name' { export function doSomething(param: string): number; }

Dealing with 'any' Types

Use any strategically during migration, then gradually replace:

// Stage 1: Quick migration with any function processData(data: any): any { return data.value * 2; } // Stage 2: Add specific types interface InputData { value: number; } function processData(data: InputData): number { return data.value * 2; } // Stage 3: Add validation function processData(data: InputData): number { if (typeof data.value !== 'number') { throw new Error('Invalid data'); } return data.value * 2; }
Best Practice: Use ESLint with @typescript-eslint/no-explicit-any rule to track and gradually eliminate any types as you convert files.

Enabling Strict Mode Incrementally

Enable strict checks one at a time:

{ "compilerOptions": { // Step 1: Start here "strict": false, "noImplicitAny": false, // Step 2: Enable after converting utility files "noImplicitAny": true, // Step 3: Enable after cleaning up null checks "strictNullChecks": true, // Step 4: Enable for classes "strictPropertyInitialization": true, // Step 5: Enable for function types "strictFunctionTypes": true, // Step 6: Enable remaining strict flags "strictBindCallApply": true, "noImplicitThis": true, "alwaysStrict": true, // Final step: Enable full strict mode "strict": true } }

Common Migration Pitfalls and Solutions

Pitfall 1: Overusing Type Assertions

// ❌ Bad: Too many assertions const data = JSON.parse(jsonString) as User; const users = response.data as User[]; // ✓ Better: Runtime validation function parseUser(data: unknown): User { if ( typeof data === 'object' && data !== null && 'id' in data && 'name' in data ) { return data as User; } throw new Error('Invalid user data'); } const user = parseUser(JSON.parse(jsonString));

Pitfall 2: Ignoring Errors with @ts-ignore

// ❌ Bad: Suppressing errors // @ts-ignore const value = obj.unknownProperty; // ✓ Better: Fix the type interface MyObject { unknownProperty?: string; } const obj: MyObject = {}; const value = obj.unknownProperty;

Pitfall 3: Not Using Unknown for External Data

// ❌ Bad: Assuming API data shape async function fetchUser(id: number): Promise<User> { const response = await fetch(`/api/users/${id}`); return response.json(); // Returns any! } // ✓ Better: Validate external data async function fetchUser(id: number): Promise<User> { const response = await fetch(`/api/users/${id}`); const data: unknown = await response.json(); if (isUser(data)) { return data; } throw new Error('Invalid user data from API'); } function isUser(data: unknown): data is User { return ( typeof data === 'object' && data !== null && 'id' in data && typeof (data as User).id === 'number' && 'name' in data && typeof (data as User).name === 'string' ); }

Migration Checklist

Step-by-Step Migration Process:
  1. Install TypeScript and initialize tsconfig.json
  2. Set allowJs: true, checkJs: false, strict: false
  3. Install @types for all dependencies
  4. Rename utility/helper files first (.js → .ts)
  5. Add types to function signatures
  6. Create interfaces for data structures
  7. Convert components/modules one by one
  8. Enable noImplicitAny: true
  9. Enable strictNullChecks: true
  10. Enable remaining strict flags
  11. Remove all any types
  12. Enable strict: true
  13. Run full type check: tsc --noEmit

Tools to Help Migration

# ESLint with TypeScript npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin # Type coverage tool npm install --save-dev type-coverage # Check type coverage npx type-coverage --detail # Automated migration tools npm install --save-dev ts-migrate npx ts-migrate-full <project-directory>

Real-World Migration Example

// Before: user-service.js class UserService { constructor(config) { this.apiUrl = config.apiUrl; this.timeout = config.timeout || 5000; } async getUsers(filters) { const params = new URLSearchParams(filters); const response = await fetch( `${this.apiUrl}/users?${params}`, { timeout: this.timeout } ); return response.json(); } async updateUser(id, updates) { const response = await fetch( `${this.apiUrl}/users/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) } ); return response.json(); } } // After: user-service.ts interface Config { apiUrl: string; timeout?: number; } interface User { id: number; name: string; email: string; } interface UserFilters { name?: string; email?: string; status?: string; } type UserUpdates = Partial<Omit<User, 'id'>>; class UserService { private readonly apiUrl: string; private readonly timeout: number; constructor(config: Config) { this.apiUrl = config.apiUrl; this.timeout = config.timeout ?? 5000; } async getUsers(filters?: UserFilters): Promise<User[]> { const params = new URLSearchParams(filters as Record<string, string>); const response = await fetch( `${this.apiUrl}/users?${params}`, { signal: AbortSignal.timeout(this.timeout) } ); if (!response.ok) { throw new Error(`Failed to fetch users: ${response.statusText}`); } const data: unknown = await response.json(); if (Array.isArray(data)) { return data as User[]; } throw new Error('Invalid response format'); } async updateUser(id: number, updates: UserUpdates): Promise<User> { const response = await fetch( `${this.apiUrl}/users/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), signal: AbortSignal.timeout(this.timeout) } ); if (!response.ok) { throw new Error(`Failed to update user: ${response.statusText}`); } return response.json() as Promise<User>; } } export { UserService, type Config, type User, type UserFilters, type UserUpdates };

Measuring Migration Progress

# Count TypeScript vs JavaScript files echo "TypeScript files:" && find src -name "*.ts" -o -name "*.tsx" | wc -l echo "JavaScript files:" && find src -name "*.js" -o -name "*.jsx" | wc -l # Type coverage npx type-coverage --detail # Sample output: # 2345 / 3000 87.50% # type-coverage success: >= 80.00%
Warning: Don't rush the migration. It's better to have 50% of your code properly typed than 100% filled with any types. Take time to understand types and add proper validation.
Exercise:
  1. Take a small JavaScript project (or create one) with at least 10 files
  2. Initialize TypeScript with migration-friendly settings
  3. Convert utility files first, adding proper types
  4. Create interfaces for all data structures
  5. Enable noImplicitAny and fix all errors
  6. Enable strictNullChecks and handle null/undefined properly
  7. Measure type coverage before and after
  8. Document lessons learned and common patterns you encountered