refactoring
Incremental migration strategies for legacy TypeScript
Learn how to modernize legacy TypeScript codebases through small, bounded changes that avoid stalled rewrites and half-finished migration sprints.

A 50,000-line TypeScript codebase written in 2018 looks nothing like one started today. Modules export entire objects instead of individual functions, any appears in half the interfaces, barrel exports create circular dependencies you've given up trying to untangle, and the tsconfig has "strict": false because turning it on would surface 3,000 errors.
The instinct is to schedule a "modernization sprint" or declare TypeScript bankruptcy and rewrite. Both fail. The sprint drags into weeks, other work stalls, and you merge a half-updated codebase that's worse than what you started with. The rewrite never finishes. What works is incremental migration: small, releasable changes that stack toward a coherent end state.
Set boundaries before you change code
The first mistake is touching files at random. You fix any types in a component, then discover that component imports types from six other modules also full of any. Without boundaries, every change explodes into a dozen dependencies.
Define migration zones tied to actual compilation units or feature boundaries. For a monorepo, a zone might be one package. In a single-package app, it might be a route handler and its direct imports, or a set of utility modules with clear dependents. The key property: you can point to a discrete set of files and say "nothing outside this set should block us from finishing here."
Enable stricter checks inside that zone while leaving the rest alone. TypeScript 5.0+'s per-file // @ts-check granularity helps, but you'll likely want a separate tsconfig.zone.json that extends the root config:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
},
"include": ["src/payments/**/*"]
}
Run tsc --project tsconfig.zone.json in CI alongside your main build. The zone can fail its own checks without breaking the world. Once that zone is clean, fold it back into the main strict config and move to the next boundary.
Replace any with unknown, not with guesses
Your legacy code is full of any. The naive fix is to replace any with a specific type you think is correct. This is type wishcasting—you're asserting what the code should do, not what it does do.
Better: replace any with unknown, then let the type-checker tell you where runtime checks are missing. unknown forces you to narrow before use:
// Before
function parseResponse(data: any) {
return data.results.map((r: any) => r.id);
}
// Not this
function parseResponse(data: { results: Array<{ id: string }> }) {
return data.results.map(r => r.id);
}
// This
function parseResponse(data: unknown) {
if (!isObject(data) || !Array.isArray(data.results)) {
throw new Error("Invalid response shape");
}
return data.results.map(r => {
if (!isObject(r) || typeof r.id !== "string") {
throw new Error("Invalid result item");
}
return r.id;
});
}
Yes, it's verbose. That verbosity is honesty. Your legacy code has been lying about its contracts. unknown surfaces the lie. Once you see where the runtime shape checks should go, you can factor them into a type guard or a validation library like Zod. The key is making the uncertainty explicit before you hide it behind a cleaner API.
One import style, enforced at build time
Legacy codebases mix CommonJS require(), ES modules import, default exports, named exports, and namespace imports. Trying to refactor when half your files use import * as utils and half use import { foo } from './utils' is like doing surgery in a cluttered room.
Pick one style for new code and one migration path for old code. Typically: prefer named exports, avoid default exports, use explicit import { ... } over namespace imports. Then enforce it:
// eslint config or biome.json
{
"rules": {
"import/no-default-export": "error",
"import/no-namespace": "warn"
}
}
For the migration itself, tackle one module at a time. Convert a leaf module (no internal dependencies) from default export to named exports. Update its importers in the same PR. Commit. Repeat. Trying to convert an entire dependency tree in one go creates a PR no one will review.
Goatfied's agent loop can help here: the planner identifies a module and its direct importers, the constraint phase ensures the change compiles in isolation, and the validator runs your lint + type-check before committing. Small diffs mean you can reverse a bad migration decision without unwinding a week of work.
Inline barrel exports or accept the performance hit
Barrel exports (index.ts files that re-export everything from a directory) seemed elegant in 2018. They're now a known performance and circular-dependency trap. Every import from the barrel loads the entire module graph.
You have two options. Option one: inline the exports. Replace:
import { Button, Input } from './components';
with:
import { Button } from './components/Button';
import { Input } from './components/Input';
This is tedious but mechanical. A codemod can handle 80% of cases. The remaining 20%—where the barrel export re-exports things from multiple depths—require judgment calls about where the "real" export lives.
Option two: keep the barrels but make them explicit about cost. Document which barrels are expensive (those that import large dependencies), and use import linters to flag when someone imports the whole barrel in a hot path. This is a pragmatic middle ground if you don't have bandwidth to eliminate barrels entirely.
The worst move is doing nothing. Barrel exports slow down cold starts, break tree-shaking, and create circular dependency chains that surface as mysterious undefined values at runtime.
Migrate tests in lockstep, not after
Developers treat test refactoring as cleanup work to do "later." Later never comes, and you end up with half your tests using the old API and half using the new one. Reviewing PRs becomes archeology: "Is this test broken, or is it just testing the legacy path?"
Instead: every production code migration PR includes test updates. If you're changing a function signature, update the tests in the same commit. If you're splitting a module, move the tests into separate files that mirror the new structure. If you're removing any, add test cases that would have failed under the old unsafe types.
This is less about correctness (your tests probably passed before) and more about keeping the test suite coherent. A test suite that reflects the current architecture is a test suite people will actually maintain.
Track strictness with a coverage metric
You can't manage what you don't measure. Add a script that counts how many files pass strict: true vs. the total:
// scripts/type-strictness.ts
const strictFiles = execSync(
'tsc --noEmit --strict --listFiles | wc -l'
).toString();
const allFiles = execSync(
'find src -name "*.ts" | wc -l'
).toString();
console.log(`Strict coverage: ${strictFiles}/${allFiles}`);
Run it in CI and fail the build if the number goes down. You don't need 100% strict coverage to ship, but you need a ratchet that prevents backsliding. Every PR that adds a new file should add it under strict mode. Every PR that touches an old file is an opportunity to promote that file to strict.
This metric is coarse—it doesn't distinguish between a file with one any and a file with fifty—but it's cheap to compute and trends in the right direction.
Don't wait for perfect
The engineers who finish legacy migrations are the ones who accept that the codebase will be "mid-migration" for months. You'll have a tsconfig.strict.json sitting next to tsconfig.json. You'll have a // TODO: remove this 'any' once upstream module is fixed comment that persists for six sprints. You'll have a linter rule that's set to warn instead of error because you haven't finished the rollout.
That's fine. What matters is that each week the codebase is a little more consistent, a little more typed, a little easier to refactor. The alternative—waiting for a perfect plan before you start—means the legacy code ossifies further and the migration becomes even harder.