Introduction: When Code Starts to "Smell"
Every developer knows this feeling. You open a file you wrote three months ago, and your face falls. Somewhere deep in your soul, you hear a whisper: "I didn't write this, some newbie did." But the rearview mirror is merciless: it was you. The code works, tests pass, but with every new feature you add, you spend more and more time. Sound familiar?
This is the moment when it's time to think about refactoring TypeScript code. Not rewriting everything from scratch (that's a path to hell), but surgically precise improvements that make the code cleaner, clearer, and safer. In this article, we'll break down when to start refactoring, how to do it without pain, and why TypeScript gives you superpowers for this process.
When Should You Refactor TypeScript Code?
There is a myth that refactoring is a separate task in a sprint. In practice, the best refactoring is a continuous process. But there are clear signals that scream: "Stop! Order is needed here!"
The Rule of Three (and Two More)
If you've copied the same piece of code three times, extract it into a function or class. If you see a colleague has copied your code, that's the fourth time. TypeScript with its strict typing makes this rule especially important: duplicating types leads to data desynchronization.
Symptoms of "Dirty" Code in TypeScript
- Any-chaos: If in your project
anyappears more often thanstring, that's a red flag. TypeScript loses its purpose. - Monster functions: A function with 200+ lines that does everything: validates, parses, sends a request, and renders the UI.
- "Magic" numbers and strings:
if (status === 3)— what is 3? No one knows, except the author who has already quit. - Violation of the DRY principle: Identical interfaces scattered across 10 files.
- Tests scarier than the code: If testing one function requires starting a server, a database, and running Docker, you've gone down the wrong path.
Remember the main rule: refactoring TypeScript code should be done when adding a new feature takes longer than writing it from scratch. That's the moment of truth.
Basic Principles: How Not to Break Production
Before you start refactoring, remember the three pillars of safe refactoring:
- Tests are your insurance. Without test coverage, refactoring is Russian roulette. Write at least basic unit tests before you start.
- Small steps. Don't try to refactor the entire project in one night. One module — one commit.
- The TypeScript compiler is your friend. Use strict settings in
tsconfig.json:strict: trueis your lie detector.
Practical Refactoring Techniques with TypeScript Examples
Let's move on to the most interesting part — code. We'll look at three real situations and show how to improve them.
1. Getting Rid of any: The Power of Generics
Bad example. The function accepts anything and returns anything. This isn't TypeScript, it's JavaScript in disguise.
// BAD: any kills typingfunction getFirstElement(arr: any[]): any { if (arr.length === 0) { throw new Error('Array is empty'); } return arr[0];}
const num = getFirstElement([1, 2, 3]); // num: anyconsole.log(num.toUpperCase()); // Runtime error, but the compiler is silentGood example. Use generics. The code becomes safer, and the IDE provides type hints.
// GOOD: generics save the dayfunction getFirstElement<T>(arr: T[]): T { if (arr.length === 0) { throw new Error('Array is empty'); } return arr[0];}
const num = getFirstElement([1, 2, 3]); // num: number// console.log(num.toUpperCase()); // Compilation error: Property 'toUpperCase' does not exist on type 'number'Generics allow you to preserve type information. This is the foundation of TypeScript code refactoring: making the code more predictable without losing flexibility.
2. Breaking Down Monster Functions
Bad example. One function handles everything: validation, formatting, API call, and rendering.
// BAD: monster functionfunction processUserData(input: any): any { // Validation if (!input.name || input.name.length < 2) { throw new Error('Invalid name'); } if (!input.email || !input.email.includes('@')) { throw new Error('Invalid email'); }
// Formatting const formattedName = input.name.trim().toLowerCase(); const formattedEmail = input.email.trim().toLowerCase();
// API call const response = fetch('/api/users', { method: 'POST', body: JSON.stringify({ name: formattedName, email: formattedEmail }) });
// Rendering document.getElementById('result')!.innerText = `User ${formattedName} created`;
return response;}Good example. Each function has a single responsibility.
// GOOD: single responsibilityinterface UserInput { name: string; email: string;}
function validateUserInput(input: UserInput): void { if (!input.name || input.name.length < 2) { throw new Error('Invalid name'); } if (!input.email || !input.email.includes('@')) { throw new Error('Invalid email'); }}
function formatUserInput(input: UserInput): UserInput { return { name: input.name.trim().toLowerCase(), email: input.email.trim().toLowerCase() };}
async function createUser(data: UserInput): Promise<Response> { return fetch('/api/users', { method: 'POST', body: JSON.stringify(data) });}
function displaySuccessMessage(name: string): void { document.getElementById('result')!.innerText = `User ${name} created`;}
// Usageasync function processUserData(input: UserInput): Promise<void> { validateUserInput(input); const formattedData = formatUserInput(input); await createUser(formattedData); displaySuccessMessage(formattedData.name);}Now each function is easy to test, easy to change, and easy to understand. This is the essence of refactoring TypeScript code: turning a monolith into a set of Lego blocks.
3. Eliminating Magic Values: Enums and Constants
Bad example. Numbers and strings without context.
// BAD: magic valuesfunction getDiscount(price: number, userType: number): number { if (userType === 1) { // What is 1? return price * 0.9; } else if (userType === 2) { // What is 2? return price * 0.8; } return price;}Good example. Use enums or constants.
// GOOD: enums with meaningenum UserType { Regular = 1, Premium = 2, Admin = 3}
function getDiscount(price: number, userType: UserType): number { switch (userType) { case UserType.Regular: return price * 0.9; case UserType.Premium: return price * 0.8; case UserType.Admin: return price * 0.7; default: return price; }}
// Usageconst discount = getDiscount(100, UserType.Premium); // 80Now the code tells a story. UserType.Premium is much clearer than the number 2. This is a simple but powerful TypeScript code refactoring technique.
Refactoring Checklist: Before and After
Before you start refactoring, go through this checklist:
Before Refactoring:
- [ ] Write tests for the code you're going to change
- [ ] Make sure the current code compiles without errors
- [ ] Create a separate branch in Git
- [ ] Set up
strict: truein tsconfig.json (if not already done) - [ ] Identify the boundaries of the change: what exactly are you refactoring?
After Refactoring:
- [ ] Run all tests: they should pass
- [ ] Check that the TypeScript compiler doesn't produce errors
- [ ] Run a linter (ESLint with TypeScript rules)
- [ ] Check that the code works in the browser (or on the server)
- [ ] Delete old commented-out code
- [ ] Update documentation if the public API has changed
Conclusion: Refactoring as a Habit
Refactoring TypeScript code is not a one-time event. It's a habit that distinguishes a professional from an amateur. TypeScript gives you a unique advantage: a strict type system that catches errors at the compilation stage. Use this superpower.
Remember: clean code is not written, it's rewritten. Every time you open a file and think "this could be better" — do it. One small improvement per day results in 365 improvements per year. Your future self (and your colleagues) will thank you.
Start small. Pick one function, one component, one module. Apply the techniques from this article. And soon you'll notice that working with the codebase becomes a pleasure, not a chore.
P.S. If you have a legacy project with hundreds of any — don't despair. Start with the most critical parts. Even 10% clean code is better than 100% chaos.