Strict Mode and the Options That Matter
What each strict flag actually catches, which additional options are worth the friction, and how to turn strictness on in a codebase that was not written for it.
What each strict flag actually catches, which additional options are worth the friction, and how to turn strictness on in a codebase that was not written for it.
You inherit a codebase with "strict": false. It compiles
cleanly, has no type errors, and crashes in production twice a
week with Cannot read properties of undefined. The checker was
running the whole time and had been told not to look at the one
thing that mattered.
The foundations course told you to turn strict on. This one
explains what each check buys, which options beyond it are worth
the friction, and — the part that decides whether any of this
happens — how to tighten a large existing codebase without
producing a number nobody will ever work through.
"strict": true enables eight checks. Two of them do most of
the work.
strictNullChecks makes null and undefined separate
types rather than members of every type:
// off
const customer: Customer = findCustomer(id); // may be undefined
customer.name; // crashes
// on
const customer: Customer | undefined = findCustomer(id);
customer.name;
// ~~~~ 'customer' is possibly 'undefined'This is the single most valuable setting in the language. Every error it produces is a place that can crash.
noImplicitAny rejects a parameter with no inferable type:
function process(items) { ... }
// ~~~~~ implicitly has an 'any' typeAn implicit any is not one untyped value — it spreads. Every
expression derived from it is any, so a single untyped
parameter can switch off checking across an entire call chain.
The rest, briefly:
strictFunctionTypes function parameter types checked
contravariantly, so a handler taking
a narrower type is rejected
strictBindCallApply call/apply/bind arguments checked
strictPropertyInitialization a class field must be assigned in
the constructor or declared optional
noImplicitThis `this` of unclear type is an error
alwaysStrict emit strict-mode output
useUnknownInCatchVariables a caught error is unknown, not anystrictPropertyInitialization is the one people fight. A field
populated by a framework rather than a constructor needs either
! or an honest | undefined — and the second is usually more
accurate.
strict is a floor, not a ceiling.
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": truenoUncheckedIndexedAccess makes every index access
T | undefined:
const first = photos[0]; // string | undefined
const size = sizes["missing"]; // number | undefinedThat is accurate — the array may be empty, the key may be
absent — and without it the checker hands you a confident value
that is not there. It is the most valuable option outside
strict, and the most disruptive: every index now needs a check
or a !.
Adopt it with a helper rather than scattering assertions:
function required<T>(value: T | undefined, what: string): T {
if (value === undefined) throw new Error(`missing ${what}`);
return value;
}
const first = required(photos[0], "first photo");exactOptionalPropertyTypes distinguishes absent from
explicitly undefined:
type Update = { caption?: string };
const clear: Update = { caption: undefined };
// with the flag on: not assignable, because ? means "may be absent"That matters for any partial-update API, where "leave this alone" and "set this to nothing" are different instructions.
noImplicitOverride requires the override keyword, so
renaming a parent method produces an error rather than a
subclass method that is silently never called.
Bad — everything at once.
Good — one flag, finished this week.
Four thousand errors is not a task anyone starts. It gets reverted, and the project stays unchecked permanently — so the real cost is not the errors, it is the decision it forces.
Two hundred is a week's work someone can see the end of. The order that works, highest value first:
noImplicitAny
Untyped parameters hide the most, and every one you fix restores checking along a whole call chain.
strictNullChecks
The large one, and the valuable one. Expect this to be most of the total.
The rest of strict
Mostly satisfied already by the time you get here.
noUncheckedIndexedAccess
Disruptive, because every index access now needs handling.
exactOptionalPropertyTypes
Narrow, and worth it wherever partial updates exist.
Two mechanisms help when only part of the codebase can comply.
Project references let one package be strict while another catches up:
A separate strict config checks a growing list of files:
Both run in CI. The strict list only grows, and a new module starts on it.
What not to do: @ts-nocheck at the top of a file turns off
checking entirely and never comes back off.
You will need to silence something. Make it narrow and temporary:
@ts-expect-error is better than @ts-ignore in one decisive
way: it errors when there is no error to suppress. So when
the upstream package is fixed, your build tells you the comment
is stale instead of leaving it forever.
The same principle applies to any:
unknown accepts anything and permits nothing until you check,
so it contains the damage rather than spreading it.
Track what you have suppressed. grep -c "@ts-expect-error" in
CI, printed as a number that should go down, turns a growing
pile into something visible.
The other options that matter are the ones that decide whether your imports work at all:
NodeNext follows Node's real rules, which means .js
extensions in import paths. Bundler matches what bundlers do
and makes them optional.
Getting this pair wrong produces the worst error class in the
language: the checker is satisfied and the program fails at
runtime. An import that resolves in your editor and throws
ERR_MODULE_NOT_FOUND when run is nearly always this.
verbatimModuleSyntax requires import type where it applies,
which makes type-only imports explicit rather than something the
compiler infers and occasionally gets wrong when a module has
side effects.
Local settings can be bypassed. CI cannot:
Separate steps, so the log names what failed rather than giving you one red cross and a scroll.
Two more things worth wiring in. "incremental": true with a
build info file makes repeat checks much faster, which matters
when it runs on every commit. And your editor should use the
workspace TypeScript version, not its bundled one — mismatched
versions produce errors that do not reproduce on the command
line, which is a genuinely confusing hour.
You now know what each check prevents, which options beyond
strict earn their disruption, and — the part that actually
determines whether a codebase gets checked — how to adopt them
in an order someone will finish.
Next is Generics and Reusable Types, where the type system stops describing your code and starts doing work for you. A function that works for many types without losing the specific one is the difference between a type system that documents and one that prevents.
Before you move on, run tsc --noEmit on a project with
noUncheckedIndexedAccess turned on. Read the first ten errors.
In most codebases at least one is a real bug — an index access
on a list that can be empty, in a path nobody tested.
Found 4,182 errors in 291 files.Found 213 errors in 47 files.STRICT - eight checks; two do most of the work
strictNullChecks absence becomes part of the type
noImplicitAny an untyped parameter spreads `any`
through everything derived from it
strictFunctionTypes, strictBindCallApply,
strictPropertyInitialization, noImplicitThis,
alwaysStrict, useUnknownInCatchVariables
WORTH ADDING BEYOND IT
noUncheckedIndexedAccess arr[0] is T | undefined
most valuable, most disruptive
use a required() helper, not `!`
exactOptionalPropertyTypes absent vs explicitly undefined
noImplicitOverride renaming a parent method errors
noUnusedLocals etc: prefer these as LINT rules
ADOPTING ON A REAL CODEBASE
never all at once - 4,000 errors gets reverted, not fixed
1 noImplicitAny 2 strictNullChecks 3 the rest
4 noUncheckedIndexedAccess 5 exactOptionalPropertyTypes
project references: one package strict while another catches up
tsconfig.strict.json with a growing include list, both in CI
never @ts-nocheck
SUPPRESSIONS
@ts-expect-error + a reason and a ticket
errors when there is nothing to suppress -> it expires
@ts-ignore never tells you it is stale
`as unknown` then narrow, never `as any`
count them in CI; the number should go down
MODULE RESOLUTION
NodeNext + NodeNext -> .js extensions REQUIRED
ESNext + Bundler -> optional
the wrong pair = checker happy, ERR_MODULE_NOT_FOUND at runtime
verbatimModuleSyntax requires import type where it applies
skipLibCheck hides real conflicts between dependencies
CI
separate steps so the log names the failure
incremental: true for speed
the editor must use the WORKSPACE TypeScript version{ "compilerOptions": { "strict": true } }{ "compilerOptions": { "noImplicitAny": true } }{
"references": [{ "path": "./packages/core" }]
}// tsconfig.strict.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "strict": true },
"include": ["src/captions/**/*", "src/report/**/*"]
}tsc --noEmit && tsc --noEmit -p tsconfig.strict.json// @ts-expect-error - upstream types wrong, see DEF-441
const client = createClient(config);const data = response.json() as any; // no
const data = response.json() as unknown; // then narrow"module": "NodeNext",
"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true{
"scripts": {
"check": "tsc --noEmit",
"check:strict": "tsc --noEmit -p tsconfig.strict.json",
"lint": "eslint .",
"test": "vitest run"
}
}- run: npm ci
- run: npm run check
- run: npm run check:strict
- run: npm run lint
- run: npm test