tsconfig strictness: what `strict` actually flips, and the flags it leaves out
`strict: true` flips eight flags at once; `strictNullChecks` retypes every value by splitting null/undefined from `T`. The biggest wins are flags NOT in the bundle — `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` — plus migrating a loose codebase incrementally.
A service worked in dev for months. Then a teammate set "strict": true in tsconfig.json to clean up a new package — and the whole repo lit up with 1,400 errors, most of them Object is possibly 'undefined'. Nobody changed any logic. One flag re-typed every nullable value in the codebase at once. That flag is strictNullChecks, and strict turned it on along with seven siblings.
strict is one switch wired to eight
strict: true does not add a single check — it sets a default for a family of flags, each of which you can still override individually:
// tsconfig.json
{
"compilerOptions": {
"strict": true
// implies, unless you override each:
// strictNullChecks, strictFunctionTypes, strictBindCallApply,
// strictPropertyInitialization, noImplicitAny, noImplicitThis,
// useUnknownInCatchVariables, alwaysStrict
}
}Each of these changes checking only — none of them changes the emitted JS (except alwaysStrict, which prepends "use strict"). They tighten what the compiler will accept, surfacing bugs that were always latent. The override pattern matters during migration: you can turn strict on but selectively relax the loudest one.
{
"compilerOptions": {
"strict": true,
"strictNullChecks": false // temporarily; everything else stays strict
}
}| flag | what it catches | typical first reaction |
|---|---|---|
strictNullChecks | null/undefined used where a non-null value is required | thousands of possibly 'undefined' |
noImplicitAny | a parameter/variable whose type couldn’t be inferred and fell back to any | ”add a type annotation here” |
strictFunctionTypes | unsafe callback parameter variance (function args checked contravariantly) | a handler typed too widely |
strictBindCallApply | .call/.apply/.bind invoked with wrong argument types | mismatched this/args |
strictPropertyInitialization | a class field that is never assigned in the constructor | Property has no initializer |
useUnknownInCatchVariables | treating a caught error as any instead of unknown | err is unknown, must narrow |
noImplicitThis | this of implicit type any in a free function | annotate the this parameter |
alwaysStrict | (emit) parses files as strict mode + emits "use strict" | rarely visible |
strictNullChecks: the one that re-types everything
Without it, null and undefined are assignable to every type — string silently includes null. With it on, they are their own types and must be modelled explicitly:
// strictNullChecks: true
function greet(name: string) {
return name.toUpperCase(); // safe: name cannot be null/undefined
}
const el = document.querySelector(".btn"); // Element | null
el.addEventListener("click", handler);
// ^^ Error: 'el' is possibly 'null'. ts(18047)
// fix: if (el) el.addEventListener(...) — or el?.addEventListener(...)This is the flag that produces the 1,400-error wall, because it reveals every place the old type system was lying. It is also the single biggest correctness win in the whole bundle.
▸Why this works
Why is this off by default outside strict? Because TypeScript shipped before it existed (it arrived in 2.0), and enabling it on a legacy codebase is a non-trivial migration. The original “everything is nullable” behaviour is the unsound one — it lets null slip through type checks — so strictNullChecks is the flag that makes the type system actually mean what it says.
The high-value flags strict does NOT enable
Why does it matter which flags are missing? Because the most common TypeScript bugs — off-by-one array access, a missing config key returning undefined — live exactly in the gaps the strict bundle doesn’t cover. These two catch real bugs and are not part of the strict bundle — you opt in separately:
{ "compilerOptions": { "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true } }noUncheckedIndexedAccess makes every index access on an array or record return T | undefined, because the index might be out of bounds or the key absent:
// noUncheckedIndexedAccess: true
const xs = [1, 2, 3];
const first = xs[0]; // number | undefined (not number!)
first.toFixed(); // Error: 'first' is possibly 'undefined'.
const cfg: Record<string, string> = {};
const v = cfg.missing; // string | undefinedIt is loud, and almost every complaint it raises is a genuine latent bug (off-by-one, missing-key, empty-array access). The cost is real ceremony at loop bodies and lookups; the payoff is that “array access can’t be undefined” stops being a lie.
exactOptionalPropertyTypes fixes a subtle hole. Without it, { a?: string } and { a: string | undefined } are treated as equivalent — so you can assign { a: undefined } to an “optional” property. With it on, the two diverge: an optional property may be absent, but you may not explicitly write undefined into it.
// exactOptionalPropertyTypes: true
interface Opts { timeout?: number } // may be ABSENT, not set to undefined
const a: Opts = {}; // ok — absent
const b: Opts = { timeout: undefined }; // Error: undefined not assignable;
// timeout? means "missing", not "present and undefined"
const c: Opts = { timeout: 200 }; // okThis matters when code does "timeout" in opts or Object.keys(opts) — under the loose default those branches misbehave because { timeout: undefined } has the key.
Order the steps for migrating a large loose codebase to full strictness without a 1,400-error big bang:
- 1 Turn strict: true on but immediately set strictNullChecks: false to keep the build green
- 2 Fix the cheap siblings first (noImplicitAny, strictBindCallApply) — small, local error counts
- 3 Flip strictNullChecks: true and fix the null/undefined wall file-by-file (or per-directory)
- 4 Add noUncheckedIndexedAccess and exactOptionalPropertyTypes last — they surface the deepest latent bugs
With noUncheckedIndexedAccess: true, what is the type of arr[0] where const arr = [1, 2, 3]?
- 01What does `strict: true` actually do, and which eight flags does it imply?
- 02Why is strictNullChecks the most consequential flag, and what changes when it's on?
- 03What do noUncheckedIndexedAccess and exactOptionalPropertyTypes catch, and why aren't they in the strict bundle?
You now know strict is a meta-flag wiring eight checks, that strictNullChecks is the one that re-types every value, and that noUncheckedIndexedAccess and exactOptionalPropertyTypes are the opt-in flags where the deepest bugs hide. Next, module resolution — once the type checks pass, the compiler still has to find the files and packages you import; the wrong moduleResolution is the classic “works in the editor, fails at runtime” trap. Now when you see a wall of possibly 'undefined' errors after someone sets strict: true, you’ll know exactly which flag fired — and which opt-in flags to add once the dust settles.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.