Introduction: Why Refactoring Legacy Code to TypeScript is a Necessity
Working with legacy code is one of the most frequent and challenging tasks for a developer. Such code is often written in plain JavaScript without strict typing, contains tangled dependencies, and lacks a modular structure. Switching to TypeScript doesn't just add types — it's a fundamental improvement in code quality that reduces the number of bugs, simplifies refactoring, and accelerates onboarding for new developers.
In this article, we will break down the step-by-step process of refactoring legacy code to TypeScript. You will learn how to safely introduce types, break down monolithic functions, and test changes.
Step 1. Analysis and Preparation of Legacy Code
Before writing code, it is necessary to understand the current architecture. Without this, refactoring will turn into chaos.
Create a Dependency Map
Start by analyzing files and modules. Identify:
- Which functions are "pure" and which have side effects.
- Where global variables and states are located.
- Which sections of code are duplicated.
Write Tests for Critical Sections
Refactoring without tests is playing Russian roulette. Write at least unit tests for key functions. Example of a test in TypeScript:
// Original legacy code (JavaScript)function calculatePrice(items) { let total = 0; for (let i = 0; i < items.length; i++) { total += items[i].price * items[i].quantity; } return total;}
// Test in TypeScript (Jest)describe('calculatePrice', () => { it('should calculate total price correctly', () => { const items = [ { price: 10, quantity: 2 }, { price: 5, quantity: 3 } ]; expect(calculatePrice(items)).toBe(35); });});If the tests pass, you can start refactoring.
Step 2. Gradual Introduction of Strict Typing
Don't try to cover everything with types at once. Use a "bottom-up" strategy: start with the simplest and most isolated functions.
Typing Simple Functions
Add types for parameters and return values. Example:
// Before (JavaScript)function greet(name) { return "Hello, " + name;}
// After (TypeScript)function greet(name: string): string { return `Hello, ${name}`;}Working with Objects and Interfaces
For complex data structures, create interfaces. This makes the code self-documenting:
// Define an interface for an order iteminterface OrderItem { id: number; name: string; price: number; quantity: number;}
// Function with a typed parameterfunction getTotalPrice(items: OrderItem[]): number { return items.reduce((sum, item) => sum + item.price * item.quantity, 0);}Use `any` Only as a Temporary Solution
The any type is the enemy of strict typing. If you don't know the exact type, use unknown and check it at runtime:
function safeParse(data: unknown): string { if (typeof data === 'string') { return data.trim(); } return '';}Step 3. Splitting Monolithic Functions and Modules
Legacy code often contains "god functions" that do everything at once. Break them down into small, reusable modules.
Extracting Pure Logic
Example: a function that validates an email and sends a request. Let's split it:
// Before (monolithic)function processUser(email) { if (!email || !email.includes('@')) { throw new Error('Invalid email'); } // ... sending request console.log('Sending request...');}
// After (separation of concerns)function validateEmail(email: string): boolean { return email