Migrating a JavaScript codebase to TypeScript
You've built something real—a working product that handles transactions, serves users, and generates revenue. Now you're staring at a JavaScript codebase that's become harder to refactor safely, and you know TypeScript could help. The challenge isn't whether to migrate; it's how to do it without freezing feature development or burning out your team.
I've led this transition twice across different platforms. The secret isn't a big-bang rewrite. It's a methodical, incremental approach that lets you ship features on day one of the migration and never stop shipping.
The Migration Steps: A Proven Path
The standard temptation is to convert everything at once. Resist it. Instead, follow this sequence:
- Add tsconfig with allowJs — Enables TypeScript to understand your JavaScript without enforcing types yet.
- Type-check JS with checkJs — Catch obvious errors in your existing code without renaming files.
- Rename files to .ts gradually — Pick a folder, rename, fix errors, ship. Repeat.
- Enable strict per folder — Raise the bar incrementally instead of everywhere at once.
- Remove any over time — As strict mode spreads, you'll find fewer reasons to use
any.
This sequence works because it creates feedback loops. You see wins early—type errors caught before production—which keeps momentum going when the grind gets heavy.
Start with a basic tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"allowJs": true,
"checkJs": false,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src"]
}
Run the TypeScript compiler against your JavaScript. It won't error—just type-check. This is your baseline.
Start at the Leaves: Utilities and Models First
Don't migrate your main application logic first. Start with files that have the fewest dependents. These are your leaves: pure utility functions, data models, constants, and helpers.
In a recent e-commerce platform migration, we started with our validation layer. These functions had clear inputs and outputs, no external dependencies beyond standard libraries. A utility like this converts trivially:
// Before (utils/validators.js)
export const isValidEmail = (email) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
export const parsePrice = (input) => {
const num = parseFloat(input);
return isNaN(num) ? 0 : num;
};
// After (utils/validators.ts)
export const isValidEmail = (email: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
export const parsePrice = (input: string | number): number => {
const num = typeof input === 'string' ? parseFloat(input) : input;
return isNaN(num) ? 0 : num;
};
These conversions are mechanical. They're also safe—if your tests pass on the TypeScript version, you're done. No need to wait for the rest of the codebase.
After utilities, migrate your data models and domain types. These usually live in separate files (like types/User.ts or models/Order.ts). They're self-contained and become the foundation for everything else.
Type the Boundaries: API Clients and DTOs Give the Most Value
This is where you get the biggest win-to-effort ratio. Your API boundaries—requests leaving your app, responses coming in—are where bugs hide.
Consider a trading platform that calls a REST API for account balances. Without types, you might do this:
// Before
const getBalance = async (accountId) => {
const res = await fetch(`/api/accounts/${accountId}/balance`);
const data = await res.json();
return data.balance; // What shape is this? Is it a number?
};
// After
interface BalanceResponse {
accountId: string;
balance: number;
currency: string;
lastUpdated: string;
}
const getBalance = async (accountId: string): Promise => {
const res = await fetch(`/api/accounts/${accountId}/balance`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: BalanceResponse = await res.json();
return data.balance;
};
Suddenly you've caught three categories of bugs before they reach production: type mismatches on the input, missing error handling, and assumptions about the response shape.
For a NestJS backend, the same principle applies to your service layer. Your DTO files become the contract between controller and business logic:
// dtos/CreateOrderDto.ts
export class CreateOrderDto {
productId: string;
quantity: number;
shippingAddress: AddressDto;
}
// services/OrderService.ts
export class OrderService {
async createOrder(dto: CreateOrderDto): Promise {
if (dto.quantity <= 0) {
throw new BadRequestException('Quantity must be positive');
}
// Process order...
}
}
Before your other files are TypeScript, your boundaries already have friction. That friction catches bugs.
Don't Stop the World: Ship Features While Migrating
The worst migration strategy is a branch that lives for three weeks while the team waits. Instead, keep a branching strategy that lets the main branch stay unblocked.
Here's what worked for us: keep a long-lived feature branch for migration work. Developers working on features merge from main into this branch, not the other way around. When migration work is done on a subsystem, merge back to main immediately.
For example, if you've migrated your authentication layer, push it. Feature developers can now import typed auth services. Meanwhile, someone else is migrating the payment module on the same branch. When that's ready, merge both.
This strategy also lets you use allowJs: true and checkJs: false for longer than you might expect. Your main branch never breaks because JavaScript and TypeScript coexist peacefully.
Enable checkJs after the foundation (utils, models, boundaries) is typed. That's when the returns on type-checking your .js files start to make sense.
Raising Strictness Incrementally: Per-folder Configs
Once you have momentum, you'll want to enable strict: true. Don't do it globally yet. Create a tsconfig.strict.json for folders that are ready:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true
},
"include": ["src/services", "src/models", "src/utils"]
}
Update your build pipeline to check both configs. This way, your migrated modules have real type safety while the rest of the codebase gradually improves. You'll move folders from the base config to strict as developers familiarize themselves with the codebase.
Common Mistakes
Mistake 1: Over-using any — When you hit a type you don't understand, resist the urge to type it any. Use unknown, then narrow it down. Or extract a helper to handle the uncertainty. The cost of any compounds as your codebase grows.
Mistake 2: Migrating bottom-up instead of top-down — If you start with deeply nested business logic, you'll rewrite it several times as the interfaces it depends on change. Start with leaves and boundaries instead.
Mistake 3: Waiting for "the right time" — Migrations are never convenient. The right time is now. If you wait for a slow period, you'll be waiting forever.
Mistake 4: Skipping tests during migration — Keep your test coverage high. When you rename a file from .js to .ts, your tests should all pass immediately. If they don't, the type system isn't your bottleneck—the logic is.
Mistake 5: Migrating without updating your linter — Add TypeScript linting rules (via @typescript-eslint) early. It catches inconsistencies your compiler misses and makes code reviews easier.
Key Takeaways
- Start with
allowJsandcheckJs: false. This lets you ship and build confidence before enforcing types everywhere. - Migrate leaves first: utilities, models, constants. These are mechanically simple and build momentum.
- Type your boundaries aggressively. API clients and DTOs give the most value for the least effort.
- Ship features during the migration. Use a long-lived branch or trunk-based development with feature flags to keep main unblocked.
- Raise strictness per-folder, not globally. This lets different parts of your codebase mature at different rates.
- Avoid
anyin favor ofunknown. Be specific about what you don't know, and handle it explicitly. - Expect the migration to take months if your codebase is large. That's not a failure—it's a sign of a real, working product.






Comments
Loading comments…