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:
- Install TypeScript and initialize tsconfig.json
- Set
allowJs: true, checkJs: false, strict: false
- Install @types for all dependencies
- Rename utility/helper files first (.js → .ts)
- Add types to function signatures
- Create interfaces for data structures
- Convert components/modules one by one
- Enable
noImplicitAny: true
- Enable
strictNullChecks: true
- Enable remaining strict flags
- Remove all
any types
- Enable
strict: true
- 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:
- Take a small JavaScript project (or create one) with at least 10 files
- Initialize TypeScript with migration-friendly settings
- Convert utility files first, adding proper types
- Create interfaces for all data structures
- Enable
noImplicitAny and fix all errors
- Enable
strictNullChecks and handle null/undefined properly
- Measure type coverage before and after
- Document lessons learned and common patterns you encountered