TypeScript Strict Mode: The Incremental Path
Turning on strict flags one by one in a live codebase — no big-bang migration required
#Programming #SoftwareEngineering #TypeScript #CodeQuality #JavaScript

Flip "strict": true in a tsconfig.json that's been loose for years, and watch the terminal fill with errors faster than you can scroll. Thousands of them, concentrated in exactly the files nobody wants to touch — the auth guard, the DTO mappers, the controller that predates the current team. Someone opens the PR anyway, usually on a Friday, usually with good intentions.
It sits for two weeks. It breaks CI a different way every time someone rebases. It dies with a comment that reads like a verdict: "we're not ready for strict mode yet."
I've opened that PR. More than once. Early on I treated strict mode as one switch — flip it, ship it, collect the type safety. What I actually collected was a stalled branch and a team that now flinches at the word "strict."
The switch isn't the problem. The order is.
Strict Mode: The Umbrella, Not the Starting Gate
"strict": true isn't a check. It's a shortcut that turns on a family of independent checks at once: alwaysStrict, noImplicitAny, noImplicitThis, strictBindCallApply, strictBuiltinIteratorReturn, strictFunctionTypes, strictNullChecks, strictPropertyInitialization, and useUnknownInCatchVariables — each listed in the TSConfig reference. Each one defaults to off. Setting strict just flips all of their defaults to on at the same time.
That's the mechanism the whole incremental path leans on. Every flag under the umbrella is individually addressable — you can turn one on, leave the rest alone, and the compiler only surfaces the errors that flag cares about. Nothing requires you to enable them together.
"strict": true is what you write down after the constituent flags already pass. Not a starting gate.
There's a second reason to treat the umbrella as a moving target. Future TypeScript versions can add new checks under strict, so a codebase already on "strict": true can start failing on a version bump for a rule that didn't exist when it last passed. A team that only remembers "we're strict" has no map for where that addition lands. A team that remembers which flag covers what can diagnose the new failure without re-learning the umbrella from scratch.
The Big-Bang PR: Where Strict Migrations Go to Die
The overnight version looks like discipline.
It's the slowest path available.
A single PR that flips the umbrella produces a diff too large to review honestly. Reviewers skim. Errors get silenced with as any just to make the red numbers go away, because the PR has to land before the branch drifts further from main. Six months later nobody remembers which casts were "temporary" and which ones are load-bearing.
Here's the part that surprises people: the codebases that suffer worst aren't the sloppy ones. They're the careful ones — the teams that shipped conscientiously under loose settings for years, kept their code clean by eye, and never had a compiler forcing the nullability question. Loose mode doesn't remove that debt. It just declines to show it to you. The flag flip doesn't create the null-and-any debt; it just makes years of it visible on the same afternoon — which is why large-codebase migrations treat a single-switch flip as a delivery halt, not a win.
An incremental path doesn't avoid that reckoning. It schedules it.
One flag's worth of debt. One reviewable diff. Then the next.
The Flag Ladder: An Order That Doesn't Lie to You
Not every flag under strict costs the same to turn on, and the docs don't rank them for you. Practitioners who've actually done this migration converge on roughly the same order, and it's worth stealing outright:
- The cheap rungs first —
alwaysStrict,noImplicitThis,useUnknownInCatchVariables,strictBindCallApply,strictFunctionTypes. These catch real bugs but rarely explode the error count; most codebases pass most of them with a handful of fixes (bite-order write-ups track the same cheap-first pattern). noImplicitAny— the first flag that actually hurts. It forces you to name every parameter and return type the compiler couldn't infer.strictPropertyInitialization— surfaces classes and entities that assumed a property would be set somewhere downstream and never told the compiler how.strictNullChecks— last, and by a wide margin the most disruptive. When it's off,nullandundefinedare effectively ignored; when it's on, years of assumed non-null explode at once.
Run that ladder against a mid-size Nest service and noImplicitAny lands almost entirely at the boundary code — Express middleware signatures, event-handler callbacks, a JSON.parse result passed straight into a service method without a shape. The core business logic, the part of the app people actually reason about day to day, usually compiles clean or close to it.
strictNullChecks hits somewhere completely different. A DTO field typed against an ORM column that's nullable in Postgres but never marked optional on the entity. A query param that's string | undefined everywhere the router touches it. An Array.find result treated as guaranteed non-null three call frames downstream, because nothing ever forced the question before.
Two flags, two unrelated blast radii, two different weeks of work. That's what "strict" flattens into a single overnight PR when you flip it as one switch — and it's exactly what a ladder is for.
A mid-migration tsconfig.json on that ladder, three rungs in, looks like this:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"alwaysStrict": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"strictBindCallApply": true,
"strictFunctionTypes": true,
"noImplicitAny": true,
"strictPropertyInitialization": false,
"strictNullChecks": false,
"strict": false
}
}strict stays false on purpose, for the entire climb. It only flips once every line above it would already default to true — at which point flipping it changes nothing except the file's honesty. strictBuiltinIteratorReturn joins the family there too; it's cheap enough that most teams fold it into that final consolidation rather than giving it its own rung — same end-state pattern as incremental strict guides that replace individual flags with "strict": true only after each one already passes.
Keeping Main Green: The Contract Between Flips
One flag — or one tightly related family, like the cheap rungs above — gets its own PR. Not a directory. Not "while I'm in here." One flag, reviewed on its own, merged once tsc --noEmit passes clean under that flag.
That's the whole contract. tsc --noEmit becomes a CI gate the moment a flag lands, the same way a lint rule becomes a gate the moment it's added — no carve-out for "the old files," because "the old files" is how loose mode accumulated its debt in the first place.
Monorepos get one more lever: project references let you flip a flag package by package instead of waiting for the whole dependency graph to be ready at once.
Path-scoped or "strict for new files only" configs are fine too, but only as a bridge with an expiry, not a permanent second standard. A codebase that's strict in src/new/ and loose everywhere else forever isn't incremental. It's just two codebases sharing a repository.
Decide early whether *.spec.ts climbs the ladder in the same PR as the code it tests, or trails a release behind on purpose. Either answer works fine. The stall happens when nobody decides — test files end up permanently exempted by default, and six months later "strict" quietly means "strict except for the half of the repo that verifies it."
The Suppression Ledger: Debt You Can Actually See
Somewhere on the ladder you'll hit a call site that genuinely can't be typed cleanly yet — a third-party library lying about its own return type, a generated client, a legacy module three sprints from a rewrite. The instinct is to reach for as any and move on.
Don't.
Reach for @ts-expect-error instead — and put a ticket in the comment.
// TODO(TICKET-4821): legacy-billing-client types its response as `any`;
// remove once we're on the v3 SDK with real generics.
// @ts-expect-error — legacy client return type is untyped
const invoice = legacyBillingClient.fetchInvoice(customerId);@ts-expect-error does something as any never will: it self-destructs. If the line it's suppressing stops erroring — because you fixed the type, upgraded the library, whatever — the directive itself becomes an unused-suppression error. Nobody has to remember to clean it up. The compiler notices for you — the same reason practitioner guides prefer expect-error over ignore for self-cleaning suppressions.
That gives you something a scattering of any casts never does: a countable ledger. grep -rn "@ts-expect-error" src | wc -l is a number you can plot on a dashboard and watch trend downward, PR by PR, with a ticket attached to every entry that hasn't burned down yet. A silent as any doesn't show up in that count. It just types forever, teaches the next engineer that the boundary was never a boundary, and gets copy-pasted into the next controller that touches the same API.
@ts-ignore still has one legitimate use — a larger project where new errors have no clear owner, or a mid-upgrade where a line errors in one TypeScript version and not another, which is exactly when the TypeScript 3.9 guidance still picks ignore. Reach for it rarely. @ts-expect-error, on a specific line, with a ticket, is the honest default.
The tracked suppression isn't a failure of the migration. It's the migration being honest about what it hasn't finished yet.
Flipping the Umbrella: How You Know You're Done
You'll know the climb is over before you touch strict itself. Every constituent flag is already true, tsc --noEmit is already clean, and the suppression ledger is a short, ticketed list instead of a wall of any. Setting "strict": true and deleting the individual flag lines at that point is a formality — a certificate for work that already shipped, not a lever you pull hoping the errors sort themselves out.
The discipline doesn't relax once the umbrella's up. Refuse new code that reintroduces implicit any. Delete suppressions the moment their ticket closes instead of letting them fossilize. Treat a proposal to loosen a flag "just for this one module" as the same regression it would have been before you climbed the ladder.
Order beats speed. Tracked debt beats hidden debt. And the umbrella flag is the last commit of the migration, not the first.
More in Build
API Rate Limiting That Doesn't Break Your Clients
Token bucket or leaky bucket is the easy choice; burst semantics, quota scope, and retry behavior are the decisions your clients inherit.
6 min · July 22, 2026
Your Microservices Release Process Is Missing the Composition Pin
Independent deploys are fine. Pretending each green pipeline is a release is how you recreate Tuesday's outage without knowing which Tuesday.
7 min · July 16, 2026
Laravel Forge vs Vapor vs EC2 vs Fargate: Pick Ops You Can Staff
The maturity ladder is marketing. Team skill and traffic shape decide the platform.
6 min · July 13, 2026