Turning on strict: true in tsconfig.json is not one setting — it is a bundle of eight separate flags, and on a codebase that grew up without them, flipping the switch can produce thousands of errors on the first compile. I've done this migration on a few different codebases now, and the pattern that works is always the same: understand what each flag actually checks, then migrate flag-by-flag instead of all at once. This is the guide I wish I'd had the first time.
What strict: true actually turns on
strict is not a real compiler check by itself — it's shorthand that enables a fixed set of individual flags. If any of them are explicitly set to false in your config, that override wins even with strict: true, which is exactly the mechanism the incremental migration below relies on. The bundle is:
- strictNullChecks —
nullandundefinedstop being silently assignable to every type. Without it, a value typedstringcan secretly benullat runtime and TypeScript won't warn you. This is almost always the highest-impact flag and the one that produces the most errors on an existing codebase, because it forces you to handle every place a value might not exist. - noImplicitAny — parameters, variables, and return types that TypeScript can't infer must be annotated, instead of silently falling back to
any. This is usually the second-biggest source of errors, especially in older code with untyped function parameters. - strictFunctionTypes — checks function parameters contravariantly instead of bivariantly. In practice: a function expecting
(x: Animal) => voidcan no longer be assigned a function typed(x: Dog) => void, because that would be unsound if the caller passes a Cat. This one mostly matters for callback-heavy APIs and rarely trips up application code. - strictBindCallApply —
Function.prototype.bind,call, andapplyget checked against the actual signature of the function they're called on, instead of returningany. - strictPropertyInitialization — class properties must be assigned in the constructor (or have a default value, or be marked
?or!). This one only fires whenstrictNullChecksis also on, and it's the flag most likely to produce noisy errors in codebases that assign fields inside a separateinit()method rather than the constructor. - noImplicitThis —
thisinside a function must have a type that TypeScript can determine; it can no longer silently beany. - alwaysStrict — emits
"use strict"in the output and parses files in ECMAScript strict mode. This is the one flag in the bundle that's about JavaScript semantics rather than the type checker, and it almost never causes migration pain. - useUnknownInCatchVariables — a caught exception is typed
unknowninstead ofany, so you can't call methods onerrwithout narrowing it first (usually withinstanceof Error).
{
"compilerOptions": {
"strict": true,
/* equivalent to explicitly setting all of:
strictNullChecks, noImplicitAny, strictFunctionTypes,
strictBindCallApply, strictPropertyInitialization,
noImplicitThis, alwaysStrict, useUnknownInCatchVariables */
"target": "ES2022",
"module": "ESNext",
"skipLibCheck": true
}
}
Why the all-at-once migration fails
The naive move is to add "strict": true and start fixing errors top to bottom. On a small project this works fine. On anything with a few years of history, it produces an error count in the thousands, most of them strictNullChecks violations cascading through the same handful of shared utility functions. Nobody can land that in one PR, so it sits on a branch, drifts out of date as `main` moves on, and eventually gets abandoned. The fix isn't more willpower — it's migrating one flag at a time, in the order that isolates the pain.
Migrating flag by flag
Instead of enabling strict directly, enable the individual flags in compilerOptions one at a time and land each as its own PR. A reasonable order:
- noImplicitThis and alwaysStrict first — low error counts, almost no design decisions involved, good for building momentum.
- noImplicitAny next — every error is "add a type annotation here." Tedious but mechanical; you can often let the TypeScript language service auto-insert the inferred type.
- strictNullChecks — do this on its own, after the above two are clean. This is where the real design decisions live: is this property actually optional, or did we just never initialize it? Budget the most time here.
- strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, and useUnknownInCatchVariables last — by this point
strictNullCheckshas usually already forced most of the fixes these flags would otherwise catch, so they tend to close out with far fewer surprises.
Once every individual flag is enabled explicitly, you can delete them all and replace them with "strict": true — at that point it's a no-op, and you've locked in that future flags added to the strict bundle (TypeScript does add new ones in major versions) get picked up automatically.
Before starting, run tsc --noEmit --strict 2>&1 | wc -l (or pipe through grep -c "error TS") to get a real error count per flag. Toggling one flag at a time and re-running gives you an honest sense of scope before you commit to an order or a timeline.
Firewalling unmigrated code with @ts-expect-error
For files you can't fix immediately — a vendored module, a file owned by another team, code scheduled for deletion — // @ts-expect-error is the right tool, not // @ts-ignore. The difference matters: @ts-ignore silently suppresses whatever error is on the next line, even after the underlying code changes and a new, different error appears. @ts-expect-error does the same suppression, but it also errors if the line it's attached to stops producing an error — which means if someone fixes the type properly later, the now-useless suppression comment gets flagged and can be deleted, instead of rotting in the codebase forever.
class UserSession {
// strictPropertyInitialization would flag this without
// a default, a constructor assignment, or a "!" assertion
private userId!: string;
init(id: string) {
this.userId = id;
}
}
function getConfigValue(key: string): string {
const value = process.env[key];
// @ts-expect-error - legacy call site still assumes env vars
// always exist; TODO(migration): audit and add null handling
return value.trim();
}
try {
riskyParse(input);
} catch (err) {
// useUnknownInCatchVariables means err is `unknown`, not `any`
if (err instanceof Error) {
console.error(err.message);
} else {
console.error("Unknown error", err);
}
}
A per-directory escape hatch works the same way at scale: keep a short list of files still under migration (a lint rule or a simple script checking for a marker comment works fine), and fail CI if a file not on that list contains an unexplained suppression comment. That keeps the debt visible and shrinking instead of quietly growing.
A common escape valve during migration is typing everything troublesome as any to make the error go away. That defeats the purpose — any disables checking for that value everywhere it flows, not just at the declaration site. If you need an honest "I don't know this type yet" placeholder, prefer unknown and narrow it at the point of use; it forces a decision instead of deferring one indefinitely.
Wrapping up
strict: true is eight flags wearing one name, and treating it as a single on/off switch is what makes migrations feel impossible. Split it back into its parts, land noImplicitAny and the small flags first, spend your real budget on strictNullChecks, and use @ts-expect-error with a paper trail — not @ts-ignore — to firewall what's left. The codebase ends up fully strict without ever needing a single PR that touches everything at once.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.