TypeScript Strict Mode for Legacy Codebases: The Incremental Adoption Strategy That Doesn't Break Everything

#TypeScript strict mode legacy codebase
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Someone on the team opens tsconfig.json, changes "strict": false to "strict": true, and runs the build. Three thousand four hundred errors. The branch gets abandoned that afternoon, and the ticket moves to the backlog with a comment saying "needs a dedicated sprint". Eighteen months later it is still there, and the codebase has grown another twenty thousand lines of loosely typed code.

This is the normal outcome of a TypeScript strict mode legacy codebase migration attempted as a single change, and the failure is structural rather than a matter of discipline. A whole-repository flag flip converts a gradual problem into an atomic one. Nobody can review a four thousand file diff, nobody can ship features while it is open, and the moment it conflicts with main the work is lost. The alternative is not to work harder on the same approach. It is to change the unit of migration from the repository to the file.

Why a TypeScript strict mode legacy codebase migration fails as one change

Three things go wrong at once when strict is enabled globally.

The error count is not evenly distributed. In a typical 50,000 line application, a handful of files generate a disproportionate share of the errors, usually the ones that touch API response shapes, form state, or anything that was originally JavaScript. Fixing those files properly means making real decisions about nullability in your domain model, which is exactly the kind of work that cannot be rushed at the end of a sprint.

The migration branch cannot survive contact with the team. Every fix touches a type definition that other files import, so the branch conflicts constantly. Long-lived refactoring branches in an actively developed repository have a short half life, and a strict mode branch is the extreme case.

And the change is all cost, no ratchet. If the branch dies, nothing has been protected. New code written next week is just as loose as the code written last year. The value of strict mode compounds only if it is enforced, and an abandoned branch enforces nothing.

The fix for all three problems is the same. Make strict mode apply to a growing list of files rather than to the repository, enforce that list in CI from the first day, and let the list grow as people touch code they were already going to touch.

The allowlist model in practice

The mechanism is a second tsconfig that is stricter than the main one and applies to a narrow set of files. Your existing tsconfig.json stays exactly as it is, so the editor, the dev server, and the production build are unaffected. Alongside it:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "strict": true,
    "noEmit": true
  },
  "include": [],
  "files": [
    "src/lib/money.ts",
    "src/lib/date-range.ts",
    "src/domain/invoice.ts"
  ]
}

Save it as tsconfig.strict.json and add a script:

"scripts": {
  "typecheck": "tsc -p tsconfig.json --noEmit",
  "typecheck:strict": "tsc -p tsconfig.strict.json"
}

CI runs both. The first keeps the existing bar. The second fails only if a file on the list regresses. A developer migrating a file adds one line to the files array in the same pull request as the fixes, which makes the migration reviewable in normal sized chunks and gives it a natural home in ordinary feature work.

One caveat worth knowing before you commit to this: tsc follows the imports of every listed file, so a badly typed module deep in the graph can still surface diagnostics. Start the list with leaf modules that import little. Utility and domain modules are the natural first candidates, and they are also where nullability bugs do the most damage.

There is an inverted variant of the same idea, where strict is enabled globally and unmigrated files carry an opt out comment. The typescript-strict-plugin package implements this with a // @ts-strict-ignore marker. It has an appealing property, which is that new files are strict by default without anyone remembering to add them to a list. It has a corresponding cost, which is that you begin by adding a comment to every file in the repository, and the plugin sits between your editor and tsc. Both models work. Choose the allowlist if you want no new tooling in the dependency tree, and the inverted model if you expect a lot of new files and want the default to favour strictness.

A note on @ts-nocheck, which comes up often in this discussion: it disables all type checking in a file, not just strict checks. It is a blunt instrument for this job and, used at scale, it removes the checking you already had. Reach for it only for generated files you do not control.

Project references are a third option, and they are the right one only when the codebase is already split into genuinely separate packages with their own build boundaries. Introducing references purely to gate strictness means restructuring your build for a linting concern, which is a large amount of work for a result the two file tsconfig approach delivers in an afternoon.

Which flags to enable first, and why the usual advice inverts here

The common recommendation is to enable the cheap flags first: alwaysStrict, noImplicitThis, strictBindCallApply, then noImplicitAny, and to leave strictNullChecks and strictPropertyInitialization until last because they produce the most errors. That ordering is correct when you are migrating the whole repository at once, because it minimises the size of the first hill.

Inside an allowlist model the calculus changes. The blast radius is already bounded by the file list, so the error count of a given flag is no longer the constraint. Value per file is. strictNullChecks is where nearly all of the runtime bug prevention lives, because it is the flag that stops undefined from silently satisfying a non optional type. Migrating a file under everything except strictNullChecks means visiting that file twice and getting most of the benefit on the second visit. Turn on the full strict meta flag for allowlisted files from the beginning and accept that each file takes longer.

Two flags deserve separate treatment. strictPropertyInitialization interacts badly with dependency injection and with ORM entity classes, where properties are legitimately assigned outside the constructor. Definite assignment assertions are the standard escape hatch there, and using them in entity classes is a reasonable engineering decision rather than a compromise. exactOptionalPropertyTypes is not part of strict at all and should stay off until the rest is stable, because it changes the meaning of every optional property in your API types at once.

If your team has not yet made the case internally for why any of this is worth doing, the argument for the flags themselves is covered in TypeScript strict mode: why your team should enable it. This post assumes the decision is made and the problem is execution at scale.

Where automated tooling helps and where it does not

Airbnb's ts-migrate is the tool most often mentioned in this context. It is genuinely useful for one specific job, which is the mechanical pass that inserts explicit annotations where the compiler would otherwise infer any, and for JavaScript to TypeScript conversion. It is worth reaching for when noImplicitAny is producing hundreds of trivially annotatable parameters. Check its maintenance status against your TypeScript version before you build a plan around it, because it has moved slowly in recent years.

It is also worth being clear about what it cannot do. ts-migrate resolves noImplicitAny violations by writing any into the source, which silences the compiler without adding type safety. That is a legitimate intermediate state if the annotations are treated as a to do list, and a permanent regression if they are not. If you use it, configure it to emit a distinctive alias such as type TODO = any so the remaining work is greppable and countable, and put that count somewhere the team sees it.

For strictNullChecks, automation is much weaker, because the correct fix is a domain decision. When the compiler flags user.company.name, the right answer might be a null check, an early return, a non null assertion backed by an invariant, or a change to the type because the API contract was wrong in the first place. Only the last of those actually removes a class of bugs, and no codemod can tell which one applies. Budget for this being human work.

The CI gate that makes it stick

Everything above is wasted if the list can silently shrink. Two checks are enough.

The first is npm run typecheck:strict, failing the build on any error in an allowlisted file. The second is a guard against removal: a small script that compares the files array in tsconfig.strict.json against the version on the main branch and fails if any path disappeared. Without it, the path of least resistance under deadline pressure is to delete a line from the config, and that will happen.

Add a third check if you can afford the argument: require that any file modified in a pull request which is not yet on the list gets added to it. This is the ratchet that makes the migration finish, because it ties strictness to the code the team is already touching, which is by definition the code where type safety pays off most. Teams that skip this step typically migrate the easy twenty percent and then stall.

What it actually costs

For a 50,000 line application with a typical mix of domain logic, React components, and API clients, a realistic shape is:

Setting up the two tsconfig files, the scripts, and the CI checks takes under a day. Migrating the first tranche of leaf utility and domain modules, perhaps fifteen to thirty files, takes two to four days and is where the team learns the patterns. From there the marginal cost is roughly ten to thirty minutes per file for ordinary modules, and considerably more for the small number of files that model external API responses, because those force the nullability conversation the codebase has been avoiding.

The total tends to land somewhere between four and eight developer weeks spread across two to three quarters, not spent as a block. What matters more than the total is that the value arrives from week one, because the ratchet stops the untyped surface from growing while the migration is still in progress.

The failure mode to watch for is a migration that stalls at forty percent because the remaining files are the genuinely hard ones and nobody wants to own the domain decisions inside them. That is not a TypeScript problem. It is usually a sign that the data model has ambiguities that predate the migration, and the compiler has simply made them visible. Treating those files as a small legacy code optimization exercise in their own right, with time allocated to fix the model rather than only the types, is what gets a stalled migration moving again.

Getting a second opinion on the plan

Most of the difficulty in this work is judgement rather than syntax: which files to migrate first, where a non null assertion is honest and where it is a lie, and how to sequence the flags against the roadmap you already committed to. If you are looking at a large TypeScript codebase and want an outside read on the sequencing before committing a quarter to it, that conversation is what code quality consulting is for.

We work on exactly this kind of incremental hardening across PHP, Symfony, React and Next.js codebases in Europe. Write to hello@wolf-tech.io or take a look at wolf-tech.io, and we will tell you plainly whether the migration is worth doing now or whether something else in the codebase deserves the quarter more.