Introduction: Why Legacy Code in TypeScript Requires a Special Approach
Working with legacy code is an inevitable reality for any developer, especially in large projects. Often, such code was written without strict typing, using any in every corner, and represents a tangled web of dependencies. Simple refactoring doesn't work here — a systematic, advanced approach is needed.
In this article, we will look at strategies for refactoring legacy code in TypeScript that go beyond basic guides. You will learn how to safely introduce strict typing, isolate changes using patterns, and leverage the compiler's capabilities to automate routine tasks.
Strategy 1: Gradual Introduction of Strict Typing via "Type Bridges"
One of the main problems with legacy code is the total use of any. Trying to replace all any with specific types at once will lead to hundreds of errors and development paralysis. Instead, we use the "type bridges" technique.
The essence of the method: we don't rewrite the entire module at once, but create intermediate bridge types that gradually displace any.
Example: Typing a "Gray" Module
Suppose we have an old module for working with users:
// legacyModule.tsexport function processUser(data: any): any { // ... a lot of unreadable code return { id: data.id, name: data.name, age: data.age };}Instead of immediately typing the entire function, we create UserData and User:
// types.tsexport interface UserData { id: number; name: string; age?: number; // field may be missing}
export interface User { id: number; fullName: string; isAdult: boolean;}Now we use these types, but keep the old signature for backward compatibility:
// legacyModule.ts (refactoring step 1)import { UserData, User } from './types';
export function processUser(data: UserData): User { const isAdult = data.age ? data.age >= 18 : false; return { id: data.id, fullName: data.name, isAdult, };}
// Old signature for compatibilityexport function processUserLegacy(data: any): any { return processUser(data as UserData);}After all consumers have switched to the new function, we delete processUserLegacy. This approach minimizes the risk of breaking dependencies.
Strategy 2: Isolating "Dirty" Dependencies Using the Adapter Pattern
Legacy code is often tightly coupled with external libraries, global states, or outdated APIs. To safely perform refactoring, you need to isolate these dependencies. The best tool for this is the Adapter pattern combined with interfaces.
Example: Isolating an Outdated API
// oldApi.ts (legacy)export function fetchOldData(callback: (data: any) => void): void { // old API with callbacks setTimeout(() => callback({ value: 42 }), 100);}We create an interface for the new approach (Promise-based) and an adapter:
// apiAdapter.tsimport { fetchOldData } from './oldApi';
export interface IDataFetcher { fetchData(): Promise<{ value: number }>;}
export class LegacyApiAdapter implements IDataFetcher { fetchData(): Promise<{ value: number }> { return new Promise((resolve, reject) => { fetchOldData((data) => { if (data) { resolve(data); } else { reject(new Error('No data')); } }); }); }}Now all new code uses IDataFetcher. When you completely rewrite the old API, you will only need to replace the adapter implementation without touching the rest of the code. This minimizes the risk of regression.