One thing I've come to appreciate about working in statically typed languages is how much easier they make refactoring. Not because the language itself restructures your code, but because of how errors behave when you make a change. If you use the type system well, errors don't scatter randomly. They lead you somewhere. And if you pick the right starting point, they lead you somewhere useful.
Code that counterbalances itself
The reason errors can guide a refactor at all comes down to how the code is written in the first place.
When you write a function with an explicit return type, or model a state with a discriminated union instead of a loose string, or define a proper interface instead of passing around an untyped map. You are encoding a contract. The compiler enforces that contract across the entire codebase. Change one side of it, and the other side breaks loudly.
// Before
type Status = string;
// After
type Status = "active" | "inactive" | "pending";Switching from string to a union type will break every place that was constructing or switching on this value in a
way that doesn't align with the new definition. Those aren't random breakages; they're the exact places that need
attention. The type system is doing the dependency analysis for you.
This is what I mean by code that counterbalances itself. A change on one side of a contract creates a force that pulls the rest of the system into alignment. Without this, changes propagate silently. You rename a field, nothing complains, and you find out weeks later in production that a consumer was still reading the old name. With good types and interfaces, that breakage is immediate and loud.
Writing code this way is an investment in future refactoring. Every explicit type, every well-defined interface, every structured error type instead of a bare exception is a guardrail that makes the codebase easier to move in later.
Start from the right place
This is where most refactors go wrong, not in execution but in where they begin.
If you change a low-level utility that 20 modules depend on, you get 20 errors pointing in 20 different directions. You jump between unrelated parts of the codebase, your diff grows, and the refactor stalls. You wanted a path. You got a scatter plot.
The goal is to create a trail: a sequence where fixing one error leads naturally to the next. To do that, you need to start at the right node.
Let's say we want to add a required role field to our User type.
type User = {
id: string;
name: string;
role: "admin" | "member"; // new
};If we make this change directly at the type definition, we immediately get errors at every place a User is
constructed. That might be dozens of places. We're now playing whack-a-mole.
Instead, we should start at the source, wherever User objects are created. If there's a single mapper or factory
function, start there:
function mapRowToUser(row: DbRow): User {
return {
id: row.id,
name: row.name,
role: row.role ?? "member", // handle the new field here
};
}Now change the type. The only error is in mapRowToUser, which we already fixed. The trail went one direction and
stayed manageable.
Before making the first change, it's worth sketching the dependency structure mentally, not to plan every step, just to pick the starting node that keeps the error trail linear.
Follow the errors one at a time
Once you've made that first targeted change, the discipline is to resist fixing everything at once.
- Make the change.
- Look at all the errors. If they're a fan-out, reconsider your starting point.
- Pick the error closest to your original change in the call chain.
- Fix it. This may require its own targeted change, which spawns the next set of errors.
- Repeat until the build is clean.
Let's say we want to make a function async:
// Before
function getUser(id: string): User {
return db.find(id);
}
// After
async function getUser(id: string): Promise<User> {
return await db.find(id);
}TypeScript will now surface every caller treating the result as User instead of Promise<User>. Most are an easy
await away. But one caller is inside a forEach, which can't be async. That error points us to the next thing:
replace the forEach with Promise.all over a .map. We didn't have to find that ourselves. The error trail led
us there.
Each error has one right answer. Each fix reveals the next problem. We're navigating, not planning.
This also keeps the diff small and reviewable. A commit that says "make getUser async and update all callers" is easy to audit because the diff matches the stated intent exactly. There are no surprise changes in unrelated files.
When it breaks down
There are a few situations where errors can't fully guide you.
Loose types. If the codebase has any in TypeScript, untyped dicts in Python, or raw interface{} in Go, the
type system can't see past those escape hatches. Errors stop at the boundary of the type-safe zone. Past that point,
you're back to manual analysis. That's the cost of guardrails that weren't built.
Runtime contracts. Some invariants can't be expressed in the type system: protocol ordering, resource lifecycle, business logic rules. A change that type-checks cleanly can still break at runtime. Tests are the second error oracle here. A failing test is the same signal as a type error: something assumed the old behavior.
Circular dependencies. In tightly coupled code, fixing one error can produce five more that loop back into each other. This usually means the refactor is exposing a structural problem. The errors aren't lying. They're telling you the real scope of the work. Sometimes the right move is to step back and pick a different starting cut.
Refactoring in the agent era
The fan-out problem changes shape when you're working with an AI agent. Twenty trails instead of one is no longer necessarily a problem, as long as none of those twenty fans out further.
If the twenty errors are known patterns, an agent can work through them in parallel. The constraint isn't the number of errors; it's whether the fixes are mechanical. Known patterns are mechanical. Agents are good at mechanical.
The situation that still requires care is when one of those twenty errors is unfamiliar. If fixing it causes another wave of changes in a direction you didn't anticipate, stop. Don't let the agent continue into territory you haven't mapped. Contain it, understand the new pattern, and decide whether to handle it separately or change your starting point entirely.
If the refactor is large, don't just start. Talk to your agent first. Walk through the codebase together, identify what strategies are available, and agree on how to proceed before making the first change. An agent given free rein on a large refactor will fill the diff. A 20-page change becomes 150 pages before you've had a chance to review the direction. Keep it in conversation until you're both aligned on scope.
The rule is the same as before, just applied at a different scale. Twenty known fixes in parallel is fine. One unknown fix that fans out further is a signal to reconsider, not to brute-force.
Conclusion
If you use the type system to encode contracts, a change on one side automatically breaks the other side, loudly, at compile time. That's not a nuisance. That's the system telling you exactly what needs to change.
When you refactor, pick your starting point carefully. You want errors that form a chain, not a fan-out. Start where one fix leads to the next, and follow the trail until the build is clean.
The errors are not the obstacle to the refactor. They are the path through it.