Refactor in the Direction Errors Take You

In a well-typed codebase, errors don't just signal problems. Your starting point determines whether they lead you through the refactor or scatter you across it.

Divyanshu Pundir
April 26, 2026
5 min read

Static types make refactoring easier. Not because the language restructures your code, but because of how errors behave when you change something. If you use the type system well, errors don't scatter. They lead you somewhere. And if you pick the right starting point, they lead you somewhere useful.

Code that counterbalances itself

Errors can guide a refactor only because of how the code was written in the first place.

When you write a function with an explicit return type, 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 codebase. Change one side, and the other side breaks loudly.

// Before
type Status = string;
 
// After
type Status = "active" | "inactive" | "pending";

Switching from string to a union breaks every place that was constructing or switching on this value in a way that doesn't fit 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.

Without this, changes propagate silently. You rename a field, nothing complains, and weeks later you find out in production that a consumer was still reading the old name. With good types, that breakage is immediate and loud.

Start from the right place

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. Your diff grows, you jump between unrelated files and the refactor stalls. You wanted a path, but 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 User is constructed in dozens of places, changing the type immediately gives us dozens of errors. We're playing whack-a-mole.

The right move is to make construction flow through a single point first. If a factory function already exists, the fan-out collapses on its own:

function mapRowToUser(row: DbRow): User {
  return {
    id: row.id,
    name: row.name,
  };
}

Now change the type. The only error is in mapRowToUser, because that's the only place a User is built. Fix it there:

function mapRowToUser(row: DbRow): User {
  return {
    id: row.id,
    name: row.name,
    role: row.role ?? "member",
  };
}

The trail went one direction and stayed manageable.

Follow the errors one at a time

Once you've made the first targeted change, resist fixing everything at once.

  1. Make the change.
  2. Look at all the errors. If they're a fan-out, reconsider your starting point.
  3. Pick the error closest to your original change in the call chain.
  4. Fix it. This may require its own targeted change, which spawns the next set of errors.
  5. Repeat until the build is clean.

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 surfaces every caller treating the result as User instead of Promise<User>. Most are an easy await away. But one caller sits inside a forEach, which can't be async. That error points to the next thing: replace the forEach with Promise.all over a .map. We didn't have to find that ourselves. The trail led us there.

Each error has one right answer. Each fix reveals the next problem. We're navigating, not planning.

When it breaks down

Errors can't always 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, and 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 rules. A change that type-checks cleanly can still break at runtime. Tests are the second error oracle. 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 produces 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 isn't necessarily a problem, as long as none of those twenty fans out further.

If the 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.

Care is needed when one of those errors is unfamiliar. If fixing it causes another wave of changes in a direction you didn't anticipate, stop. Contain it, understand the new pattern and decide whether to handle it separately or change your starting point entirely.

For a large refactor, don't just start. Walk through the codebase with the agent, agree on a strategy and align on scope before the first change. An agent given free rein on a large refactor will fill the diff. A 20-page change becomes 150 before you've had a chance to review the direction.

Conclusion

Encode contracts in the type system, and a change on one side breaks the other side loudly, at compile time. That's not a nuisance. That's the system telling you exactly what needs to change.

Pick your starting point so errors form a chain, not a fan-out. Follow them one at a time until the build is clean. The errors are not the obstacle to the refactor. They are the path through it.