TypeScript config and tooling · 3 / 8
lesson 3

Strict mode in depth

What strict actually turns on, and four extra flags worth adding on top.

~ 15 min read·lesson 3 of 8
0 / 8

A teammate flips strict: true in your tsconfig.json and your editor fills with red squiggles. Hundreds of them, in code that worked yesterday. The reflex is to back the change out. The better reflex is to slow down and read what TypeScript is actually telling you — because almost every one of those squiggles is a bug the looser config was hiding.

This lesson walks through what strict actually does, and four extra flags that are worth turning on after.

What strict bundles together

"strict": true is not one rule. It is a shortcut for eight flags. Setting strict: true is the same as setting all eight individually.

The eight flags, grouped by what they catch:

  • Implicit any: noImplicitAny — refuses to silently widen unknown types to any.
  • Null safety: strictNullChecks — separates null and undefined from every other type.
  • Function arguments: strictFunctionTypes, strictBindCallApply — checks function argument types more carefully.
  • Class members: strictPropertyInitialization — class fields must be assigned in the constructor.
  • this: noImplicitThis — fails when this would silently become any.
  • use strict: alwaysStrict — emits "use strict" at the top of every file.
  • unknown for catch: useUnknownInCatchVariablescatch (e) types e as unknown instead of any.

Two of these matter far more than the others in day-to-day work. The rest are quiet helpers.

Tip

You almost never want strict: false in a new project. The friction you feel on day one becomes the bugs you don't ship in month two.

noImplicitAny — the big one

Without this flag, TypeScript will silently give a parameter the type any if you forget to annotate it. any opts every operation on that value out of type-checking.

src/cart.ts
// without noImplicitAny — quietly typed as any
function getTotal(cart) {
return cart.itms.reduce((a, b) => a + b, 0);
}

The cart parameter is any. Every property access on it is any. The typo cart.itms is allowed because any.anything is also any. The bug ships.

With noImplicitAny: true, the editor stops at function getTotal(cart) and says: "Parameter cart implicitly has an any type." You have to make a choice — annotate the parameter properly, or write cart: any if you really mean it. The point is the choice is now visible.

src/cart.ts
function getTotal(cart: { items: number[] }) {
return cart.items.reduce((a, b) => a + b, 0);
}

Now cart.itms is a typo, not a free pass. The compiler points at the misspelling.

check your understanding
You write function tally(rows) { return rows.length } with noImplicitAny off. The function "works" at runtime. What is TypeScript actually doing?

strictNullChecks — the second biggest

Without this flag, null and undefined are silent inhabitants of every type. A function declared to return string could return null and the compiler would not complain.

src/users.ts
function findUser(id: string): User {
return users.find((u) => u.id === id);
/* find() returns User | undefined — not User */
}

const name = findUser("u_42").name;
/* runtime: TypeError: Cannot read properties of undefined */

Array.prototype.find returns T | undefined. Without strictNullChecks, the compiler treats T | undefined as just T, and the unsound return slips through. With strict null checks, the editor flags the missing undefined case at the return line and again at .name.

The fix is one of three: handle the missing case, narrow with a check, or assert that it cannot be missing.

src/users.ts
function findUser(id: string): User | null {
return users.find((u) => u.id === id) ?? null;
}

const user = findUser("u_42");
const name = user ? user.name : "guest";

The signature now tells the truth. The caller is forced to handle the missing case. The TypeError is impossible.

check your understanding
With strictNullChecks on, you write const x: number = data.value where data.value is typed number | undefined. The error is "Type 'number | undefined' is not assignable to type 'number'." What is the cleanest fix when you genuinely know the value is set?

Four flags strict does not turn on

strict: true is conservative on purpose. It catches the bugs that affect everyone. There are four more flags that a team can adopt once strict feels comfortable. Each one catches a different real bug.

The four:

  • noUncheckedIndexedAccess
  • exactOptionalPropertyTypes
  • noImplicitOverride
  • noFallthroughCasesInSwitch

The rest of the lesson takes them one at a time, with the bug each one stops.

noUncheckedIndexedAccess

Indexed access — reading arr[3] or obj["key"] — returns the element type. The compiler trusts that you wouldn't read past the end of an array.

You read past the end of arrays all the time.

src/parse.ts
const parts = "alpha,beta".split(",");
const third: string = parts[2];
console.log(third.toUpperCase());
/* runtime: Cannot read properties of undefined */

parts[2] is typed string, but at runtime it is undefined. The compiler did not insert the | undefined because the default rule for indexed access is "trust the index".

With noUncheckedIndexedAccess: true, the type of parts[2] becomes string | undefined. The line third.toUpperCase() becomes a type error until you handle the missing case.

src/parse.ts
const parts = "alpha,beta".split(",");
const third = parts[2];           /* string | undefined */
if (third) console.log(third.toUpperCase());

The cost is verbosity in for loops and other places where the index is provably safe. The benefit is every off-by-one bug becomes a compile error.

Watch out

This flag is one of the highest-value additions over strict. It is also one of the noisiest — expect to fix dozens of call sites in an existing codebase before the build is clean again.

exactOptionalPropertyTypes

An optional property like name?: string means "the key may be missing". Without this flag, it also silently means "the key may be present but assigned undefined". Those two states look the same in JavaScript but behave differently — JSON.stringify drops missing keys but keeps explicit undefineds, Object.keys includes one but not the other.

src/profile.ts
type User = { name?: string };

const a: User = {};
const b: User = { name: undefined };
/* both compile without exactOptionalPropertyTypes */

a has no name key. b has a name key set to undefined. With exactOptionalPropertyTypes: true, the second line becomes a type error: explicit undefined is no longer assignable to an optional. You have to mean it — write name: string | undefined if you want both states.

The flag matters most when types cross system boundaries — JSON, databases, network requests — where "missing" and "present-but-null" carry different meanings.

check your understanding
You're modeling a record where a missing field means "use the default" but an explicit undefined means "the user actively cleared it". Which flag makes the type system distinguish those cases?

noImplicitOverride

When a subclass redefines a parent method, the relationship is implicit. Rename the parent method and the subclass quietly stops overriding anything — the subclass keeps the old method, the parent has a new one, and nothing tells you about it.

src/notifier.ts
class EmailNotifier extends Notifier {
send(message: string) {
  /* used to override Notifier.send. Parent renamed to dispatch().
     Now this is a sibling method. Nothing complained. */
}
}

With noImplicitOverride: true, every override has to be marked with the override keyword.

src/notifier.ts
class EmailNotifier extends Notifier {
override send(message: string) { /* ... */ }
}

If the parent renames send to dispatch, the override send line becomes a compile error: "This member cannot have an 'override' modifier because it is not declared in the base class." You learn at the rename, not at the user-reported bug.

noFallthroughCasesInSwitch

A switch case without a break falls through to the next case. That is sometimes intentional, mostly accidental.

src/role.ts
switch (role) {
case "admin":
  grantAccess();
case "editor":         /* whoops — admins now also fall through here */
  showEditor();
  break;
case "viewer":
  showViewer();
  break;
}

noFallthroughCasesInSwitch: true flags the case "admin" block: it has code but no break, return, or throw. You either add the missing terminator or use // fallthrough (with a deliberate comment) to opt in. Either way the silent bug becomes a visible decision.

check your understanding
You have noFallthroughCasesInSwitch on. You genuinely want one case to fall through to the next. What is the right pattern?
check your understanding
You're starting a new project. Which combination is the most defensible default?
← prevnext lesson →
KeepLearningcertificate
for completing
TypeScript config and tooling
0 of 8 read