Refactor a TypeScript Codebase Safely
Learn how to refactor large TypeScript codebases safely using the compiler as your guide, with techniques for renaming, extracting types, and restructuring modules.
Refactoring in a dynamic language is terrifying. You rename a function, search for every call site by hand, miss one, and ship a runtime crash.
In TypeScript, refactoring is different. You have a compiler that checks every reference, every type, and every import across the entire project. Change something, run tsc, and it tells you exactly what broke.
The key to safe TypeScript refactoring is letting the compiler do the work. Do not try to be clever.
Make a change, run the compiler, fix the errors it reports, and repeat. Every step is verified before you move to the next.
Rename Symbols with the Language Server
The most common refactoring is renaming something. A function, a type, a variable needs a better name.
Do not use find-and-replace. Use the rename symbol command in your editor, which is powered by the TypeScript language server.
The language server renames every reference, including in type positions and string literal types. A find-and-replace on "userId" would also change "getUserIdFromToken" to "getCustomerIdFromToken", which is wrong. The language server knows the difference between the symbol and a substring.
After renaming, run the compiler immediately. The language server is good but not perfect, and it might miss references in dynamically constructed property access or in files that are excluded from the project.
A clean compile confirms the rename is complete.
Extract a Type Safely
A type that started simple grows over time. Five optional fields, two union members, and a nested object later, it belongs in its own file. Extracting a type means moving it without breaking any consumer.
First, identify every file that imports the type. The compiler can help: temporarily remove the type and run tsc. Every error points to a file that needs to be updated.
Move the type to its new home in a dedicated file. The new file should export nothing but types so it stays pure and framework-independent:
// features/checkout/types.ts
export type CheckoutState =
| { step: "cart"; items: CartItem[] }
| { step: "shipping"; items: CartItem[]; address: ShippingAddress }
| { step: "payment"; items: CartItem[]; total: number };Now update each consumer one at a time. Change the import path from the old location to the new one, run tsc, and fix any remaining errors from that consumer before moving to the next. This incremental approach means you always have a clear diff and never face a wall of fifty broken files at once.
Change a Function Signature
Changing what a function accepts or returns is the refactoring that breaks the most callers. The approach is the same: make the change, run tsc, and fix every call site the compiler reports.
If you are adding a required parameter, consider making it optional first with a default value. This lets existing callers keep compiling while you update them:
// Before
function formatPrice(amount: number): string {
return `USD ${amount.toFixed(2)}`;
}
// During migration -- old callers still work
function formatPrice(amount: number, currency?: string): string {
return `${currency ?? "USD"} ${amount.toFixed(2)}`;
}
// After migration -- remove the default
function formatPrice(amount: number, currency: string): string {
return `${currency} ${amount.toFixed(2)}`;
}The three-step pattern works for any signature change. Step one adds the new parameter as optional with a sensible default.
Step two updates every caller to pass the new parameter explicitly while tsc confirms nothing is missed. Step three makes the parameter required and removes the default fallback.
Restructure Module Boundaries
Moving code between packages is a larger refactoring but follows the same principles. The trick is to keep the old public API working while you migrate consumers. This is where barrel files earn their keep.
When moving a function from one package to another, leave a re-export in the old location temporarily. The re-export keeps existing imports working while you move the actual code:
// OLD: features/checkout/pricing.ts
// This file now just re-exports from the new location
export { calculateDiscount } from "../../domain/billing/discounts";The migration happens in three clear phases. First, move the implementation to the new package and add the re-export in the old location.
Second, update each consumer's import path one at a time, running tsc after each change to verify correctness. Third, once no consumer imports from the old path anymore, delete the re-export file entirely.
For more on organizing packages this way, see set package boundaries in TypeScript.
Widen a Type Without Breaking Callers
Sometimes a type is too narrow. A function that accepts only string should accept string or number. Widening a type is one of the few refactorings that rarely breaks consumers, but it can break the implementation.
The danger is inside the function body. If the implementation calls a string method on the parameter and you widen it to string or number, the implementation must handle both cases:
// Before: only accepts string
function greet(name: string): string {
return `Hello, ${name.toUpperCase()}`;
}
// After: accepts string or number, implementation handles both
function greet(name: string | number): string {
const display = typeof name === "number" ? String(name) : name;
return `Hello, ${display.toUpperCase()}`;
}The callers do not need to change. Passing a number to greet now compiles where it did not before, and the consumer-side diff is empty.
But the implementation needed a typeof check because toUpperCase does not exist on number.
Commit Refactors Separately
The most important rule of safe refactoring has nothing to do with TypeScript. Commit structural changes separately from behavioral changes.
A commit titled "Rename UserId to CustomerId across all packages" that only touches import paths and type names is trivial to review. A commit that renames UserId to CustomerId and also changes the authentication logic is impossible to review safely.
Small, focused commits also make git bisect useful. If something breaks, you can pinpoint the exact refactoring step that caused it rather than searching through a mixed commit.
Common Mistakes
Renaming with find-and-replace instead of the language server. The language server understands that a type parameter named T in one file is unrelated to a type parameter named T in another. Find-and-replace does not.
Refactoring without running the compiler between steps. The compiler is your safety net. If you make three changes and then run tsc, you face all the errors at once. Make one change, compile, fix, commit, repeat.
Deleting the old API before consumers have migrated. Use re-exports, optional parameters, and deprecation comments to keep old consumers working during the transition. Never delete and migrate in the same step.
Refactoring and adding features in the same commit. Reviewers cannot tell which changes are structural and which are behavioral. Split them.
For more on keeping a codebase healthy over time, see maintain large TypeScript codebases.
Rune AI
Key Insights
- Let the compiler guide every refactoring: fix one error at a time until tsc reports zero errors.
- Rename symbols with the language server, not find-and-replace, to catch all references including type positions.
- Extract shared types into dedicated files first, then update imports one module at a time.
- Commit refactors separately from feature work so reviewers can see the structural change in isolation.
- Run tests after every refactoring step; the compiler catches type errors but not logic bugs.
Frequently Asked Questions
Can I trust the TypeScript compiler to catch all refactoring mistakes?
What is the safest way to rename a symbol across a large TypeScript project?
Conclusion
The TypeScript compiler is the best refactoring tool you have. It catches every broken import, every mismatched type, and every missing property before the code ever runs. Combine compiler-guided refactoring with small, reviewable commits and a fast test suite, and large-scale changes become routine instead of risky.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.