09 · Strict Mode Best Practices¶
"strict": true is actually a bundle of eight separate flags, and
there are several more useful checks that aren't included in strict
at all and must be opted into individually. This module inspects what
each flag actually catches, and demonstrates a real gap that strict
alone doesn't close.
What strict: true turns on¶
is shorthand for:
{
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"alwaysStrict": true
}
}
strictNullChecks — the most impactful single flag¶
function findUser(id: number): { name: string } | undefined {
return id === 1 ? { name: "Ada" } : undefined;
}
const user = findUser(1);
if (user) {
console.log(user.name);
}
Without strictNullChecks, undefined is silently assignable to
everything, and user.name would compile even without the if (user)
guard — crashing at runtime for any id other than 1. With it,
user.name directly (no guard) is a compile error, forcing exactly
the check shown above before access.
strictPropertyInitialization — catches uninitialized class fields¶
class Service {
name: string;
constructor(name: string) {
this.name = name;
}
}
console.log(new Service("api").name);
If the constructor forgot to assign this.name, strictPropertyInitialization
flags name: string; itself as an error ("Property 'name' has no
initializer and is not definitely assigned in the constructor") —
without this flag, a forgotten assignment compiles fine and name is
silently undefined at runtime despite its declared type of string.
The gap: noUncheckedIndexedAccess is not part of strict¶
const scores: Record<string, number> = { ada: 100 };
const score = scores["grace"];
console.log(score.toFixed(0));
Compiled and run with --strict alone:
This compiles cleanly under full --strict. Record<string, number>'s
index signature claims every string key maps to a number — but
"grace" was never actually added to scores, so scores["grace"] is
really undefined at runtime, and score.toFixed(0) crashes. strict
alone does not catch this, because index signatures are treated as
"always present" unless told otherwise.
Adding the one extra flag:
Same code, now a compile-time error instead of a runtime crash.
noUncheckedIndexedAccess types every index-signature and array access
as T | undefined, forcing a check before use — it's not part of
strict because it was added years later and would break too much
existing "strict-clean" code by default, but most new projects should
turn it on explicitly.
Other flags worth enabling beyond strict¶
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true
}
}
noImplicitOverride requires an explicit override keyword when a
subclass method overrides a base class method — without it, renaming a
base class method silently orphans the "override" in the subclass
instead of flagging it. exactOptionalPropertyTypes distinguishes
{ x?: string } (key can be absent) from { x: string | undefined }
(key present, value undefined) — a real distinction that plain
optional properties blur by default.
Traps¶
Turning on strict for the first time on an existing large codebase
produces an overwhelming number of errors at once. Most real
migrations enable the flags individually (noImplicitAny first,
usually, then strictNullChecks) rather than flipping strict: true
all at once, fixing each flag's errors before moving to the next.
strict: true in tsconfig.json doesn't retroactively check
node_modules's own .d.ts files against the same standard — that's
what skipLibCheck controls separately, and most projects want
skipLibCheck: true alongside strict: true, not instead of it.
A flag being off doesn't mean the underlying bug can't happen — it
means the compiler won't catch it for you. The scores["grace"]
crash above happens at runtime regardless of any compiler flag; the
flags only change whether TypeScript catches it before you ship.
Adding noUncheckedIndexedAccess to an existing project surfaces
many new possibly undefined errors on code that "worked" before —
often on array access (arr[i]) throughout the codebase — because it
applies to every indexed access, not just object index signatures.
How It Actually Works¶
strict: true is not one setting but an umbrella that enables a specific list of independent checker behaviors, each of which changes a distinct part of the structural-comparison algorithm — strictNullChecks removes null/undefined from being implicitly assignable to every other type (without it, the checker treats every type as structurally including null | undefined, which is why enabling it retroactively surfaces enormous numbers of "possibly null" errors across an existing codebase: those unsafe accesses were always there, just invisible to the comparison before); strictFunctionTypes enables the contravariant parameter-checking discussed in the classes-advanced lesson for standalone function-typed values (methods keep the more lenient bivariant check regardless); noImplicitAny makes the checker report an error instead of silently inferring any whenever it can't determine a type from context, converting what would otherwise be a silent type-safety hole into a visible one.
Each flag changes a genuinely different code path inside the checker, which is why they're independently toggleable rather than fused into one binary switch — a codebase might reasonably want noImplicitAny (catch missing type annotations) without strictNullChecks (a large migration cost) as an intermediate adoption step, and tsconfig.json's design deliberately exposes that granularity because the underlying checker behaviors really are separable passes over the same structural comparison, not a single monolithic "strictness level."
Because none of these flags change emit at all — they only change which diagnostics the checker reports — toggling strict on or off never changes the compiled JavaScript output for code with zero type errors; it only changes which previously-silent type mismatches now get surfaced, which is why "turning on strict mode broke my build" always means the unsafe code was already there, not that the new setting introduced new runtime behavior.
Cheat sheet¶
| Flag | In strict? |
Catches |
|---|---|---|
noImplicitAny |
Yes | Untyped parameters/variables defaulting to any |
strictNullChecks |
Yes | Using a possibly-null/undefined value without a check |
strictPropertyInitialization |
Yes | Class fields never assigned in the constructor |
strictFunctionTypes |
Yes | Unsound function parameter variance |
noUncheckedIndexedAccess |
No | Index/array access assumed always present |
noImplicitOverride |
No | Subclass method silently no longer overriding anything |
exactOptionalPropertyTypes |
No | Blurring "key absent" vs. "key present, value undefined" |
Exercise¶
Take the scores example and add noUncheckedIndexedAccess: true to a
real tsconfig.json. Fix the resulting error properly — not by
suppressing it, but by adding a check (if (score !== undefined)) —
and confirm the fixed version both compiles under
--noUncheckedIndexedAccess and runs without throwing when queried for
a key ("grace") that was never added.