TypeScript strict isn't enough
Most projects stop at `"strict": true`. They shouldn’t. Here are the four extra flags I add to every project, why each one matters, and the upgrade path that won’t overwhelm your team.
strict isn't enough
Most TypeScript projects stop at "strict": true. They shouldn't.
strict is a meta-flag that turns on six others: alwaysStrict, noImplicitAny, noImplicitThis, strictBindCallApply, strictFunctionTypes, strictNullChecks, strictPropertyInitialization. These are good. They are not enough.
There are four more flags I add to every project I touch. Each one catches a real category of bug that strict doesn't. Each one will, the first time you turn it on, surface dozens of pre-existing bugs in your codebase. That's the point.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true
}
}Each flag, in order.
noUncheckedIndexedAccess
Without this flag, an array access returns the element type:
const xs: string[] = [];
const x = xs[0]; // type: string
console.log(x.toUpperCase()); // ✅ compiles, ❌ runtime crashThis is wrong. The actual type is string | undefined, because xs[0] on an empty array is undefined. strict doesn't catch it.
With noUncheckedIndexedAccess: true, TypeScript correctly types the access:
const xs: string[] = [];
const x = xs[0]; // type: string | undefined
console.log(x.toUpperCase()); // ❌ Object is possibly 'undefined'You're forced to handle the undefined case. Either:
const x = xs[0];
if (x) console.log(x.toUpperCase());Or with optional chaining:
console.log(xs[0]?.toUpperCase());The cost: every array index now needs a check. The benefit: this category of bug ("forgot the array could be empty") becomes structurally impossible.
The same applies to object access by string key:
const lookup: Record<string, number> = { a: 1, b: 2 };
const v = lookup['c']; // type: number | undefinedThis catches "forgot the key might not exist" — which is the most common bug in TypeScript code that traffics in records.
noImplicitOverride
Without this flag, when you override a method on a class, you don't have to mark it. This means a typo in the method name silently creates a new method instead of overriding:
class Base {
log() { console.log('base'); }
}
class Child extends Base {
log_() { console.log('child'); } // typo — silently a new method
}
new Child().log(); // logs 'base'. The override never happened.With noImplicitOverride: true, you must mark overrides:
class Child extends Base {
override log_() { ... } // ❌ no method 'log_' on Base to override
}The typo becomes a compile error. The category — "renamed a method on the parent and forgot to update the override" — becomes impossible.
Cost: a small amount of typing. Benefit: real. The first time this catches a real bug, you'll be glad.
exactOptionalPropertyTypes
This is the most controversial one because it changes how optional properties behave.
Default behavior: { x?: number } allows x to be number | undefined or missing entirely. They're treated the same.
type T = { x?: number };
const a: T = {}; // ok
const b: T = { x: 5 }; // ok
const c: T = { x: undefined }; // ok — but is this what you meant?exactOptionalPropertyTypes: true makes the distinction explicit:
type T = { x?: number };
const a: T = {}; // ok
const b: T = { x: 5 }; // ok
const c: T = { x: undefined }; // ❌ Type 'undefined' is not assignableTo allow explicit undefined, declare it:
type T = { x?: number | undefined };Why bother? Because APIs that accept "missing" and APIs that accept "explicit undefined" are different APIs and they should be typed differently. Most JSON serializers drop undefined keys. Most TypeScript types assume "missing means undefined." Both are conventions, not laws. exactOptionalPropertyTypes forces you to be honest about which one your code requires.
Real-world bug it catches: a partial-update API where {} means "don't change anything" but { name: undefined } means "set name to null." Without this flag, both look identical. With it, they don't.
useUnknownInCatchVariables
Default behavior: catch clauses give you any. This is one of the last places any survives in modern TypeScript:
try { ... }
catch (e) {
console.log(e.message); // type: any. Could be anything.
}useUnknownInCatchVariables: true (which is also turned on by strict in TS 4.4+) types e as unknown:
try { ... }
catch (e) {
console.log(e.message); // ❌ Object is of type 'unknown'
}You're forced to narrow:
catch (e) {
if (e instanceof Error) console.log(e.message);
else console.log(String(e));
}Cost: every catch clause gets two more lines. Benefit: you stop assuming everything thrown is an Error (it isn't — JavaScript lets you throw "string" or throw 42 or throw null).
Note: this is included in strict from TS 4.4+. I list it explicitly because some teams are on older TS versions where strict alone won't enable it.
the upgrade path
If you're adding these to an existing project, the order matters. Each one will surface errors. Doing them all at once is overwhelming.
I do them one at a time, in this order:
useUnknownInCatchVariablesfirst. It catches a small number of issues, all in error-handling code, mostly mechanical to fix.noImplicitOverridenext. Catches a small number of issues, mostly in classes (which most modern TypeScript codebases don't use heavily), so the surface is small.exactOptionalPropertyTypesnext. This one will surface real bugs. Take time with it. Some you'll want to fix; some you'll want to migrate the type tox?: number | undefinedto preserve old behavior intentionally.noUncheckedIndexedAccesslast. This is the biggest one. It will surface dozens to hundreds of issues in a real codebase. Treat it as a project, not a flip.
For each flag, expect a day per ten thousand lines of code, plus a code review pass.
the meta-point
Type safety is a continuum, not a binary. strict is the floor, not the ceiling. Every project I work on has these four extra flags on, and every codebase that passes them is more reliable than one that doesn't — not by a margin you can measure with tests, but by a margin you can feel after six months when bugs in production start looking different.
If you're starting a new TypeScript project, copy the JSON above into your tsconfig.json from day one. The cost is negligible at the start; the benefit compounds.