open atlas
↑ Back to track
TypeScript type system, deep TS · 07 · 01

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.

TS Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

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
  }
}
flagwhat it catchestypical first reaction
strictNullChecksnull/undefined used where a non-null value is requiredthousands of possibly 'undefined'
noImplicitAnya parameter/variable whose type couldn’t be inferred and fell back to any”add a type annotation here”
strictFunctionTypesunsafe callback parameter variance (function args checked contravariantly)a handler typed too widely
strictBindCallApply.call/.apply/.bind invoked with wrong argument typesmismatched this/args
strictPropertyInitializationa class field that is never assigned in the constructorProperty has no initializer
useUnknownInCatchVariablestreating a caught error as any instead of unknownerr is unknown, must narrow
noImplicitThisthis of implicit type any in a free functionannotate 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 | undefined

It 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 };          // ok

This 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

Order the steps for migrating a large loose codebase to full strictness without a 1,400-error big bang:

  1. 1 Turn strict: true on but immediately set strictNullChecks: false to keep the build green
  2. 2 Fix the cheap siblings first (noImplicitAny, strictBindCallApply) — small, local error counts
  3. 3 Flip strictNullChecks: true and fix the null/undefined wall file-by-file (or per-directory)
  4. 4 Add noUncheckedIndexedAccess and exactOptionalPropertyTypes last — they surface the deepest latent bugs
Quiz

With noUncheckedIndexedAccess: true, what is the type of arr[0] where const arr = [1, 2, 3]?

Recall before you leave
  1. 01
    What does `strict: true` actually do, and which eight flags does it imply?
  2. 02
    Why is strictNullChecks the most consequential flag, and what changes when it's on?
  3. 03
    What do noUncheckedIndexedAccess and exactOptionalPropertyTypes catch, and why aren't they in the strict bundle?
Recap

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.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.