Strict mode in depth
What strict actually turns on, and four extra flags worth adding on top.
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 toany. - Null safety:
strictNullChecks— separatesnullandundefinedfrom 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 whenthiswould silently becomeany.use strict:alwaysStrict— emits"use strict"at the top of every file.unknownfor catch:useUnknownInCatchVariables—catch (e)typeseasunknowninstead ofany.
Two of these matter far more than the others in day-to-day work. The rest are quiet helpers.
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.
// 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.
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.
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.
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.
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.
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:
noUncheckedIndexedAccessexactOptionalPropertyTypesnoImplicitOverridenoFallthroughCasesInSwitch
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.
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.
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.
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.
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.
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.
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.
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.
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.
noFallthroughCasesInSwitch on. You genuinely want one case to fall through to the next. What is the right pattern?