TypeScript Strict Mode, Which Flags Actually Pay for Themselves
TL;DR —
strict: trueis the right default; turn it on for new projects on day one / For migrations, ratchet up one flag at a time /noUncheckedIndexedAccessis the most underrated extra; turn it on too.
I read a lot of tsconfig.json files in 2022 — onboarding to projects, reviewing OSS code, helping teams plan migrations. The most common smell: strict set to false, or worse, half the strict sub-flags individually disabled with no comment explaining why.
TypeScript’s strict mode isn’t aspirational anymore. In 2023, every well-maintained TS codebase has it on. This post walks the flags one by one — what each prevents, what it costs to enable, and where I’d argue extras beyond strict are worth the noise. It’s a companion piece to the migration playbook earlier this month.
What strict: true actually turns on
strict: true is a shorthand. As of TypeScript 4.9 it enables eight sub-flags:
noImplicitAnystrictNullChecksstrictFunctionTypesstrictBindCallApplystrictPropertyInitializationnoImplicitThisalwaysStrictuseUnknownInCatchVariables
Each can be set individually. The recommended migration path is to leave strict: false and enable these one at a time. Let’s go through them.
noImplicitAny
What it does: errors when a variable, parameter, or return type could not be inferred and would default to any.
// noImplicitAny: false
function add(a, b) { // a and b are implicit any
return a + b;
}
// noImplicitAny: true
function add(a, b) { // error: parameter 'a' implicitly has 'any' type
return a + b;
}
Cost to enable: usually low on a small codebase, high on a sprawling untyped one. You annotate every function parameter and a lot of Record<string, any> you didn’t notice before.
Verdict: non-negotiable. Without this, TypeScript barely catches anything. Turn it on first.
strictNullChecks
What it does: separates null and undefined from other types. T no longer allows null; you have to write T | null or T | undefined.
// strictNullChecks: false
const user: { name: string } = null; // allowed
user.name; // crash at runtime
// strictNullChecks: true
const user: { name: string } = null; // error
const maybeUser: { name: string } | null = null;
maybeUser.name; // error: 'maybeUser' is possibly 'null'
Cost to enable: high. This is the painful flag. On a 50k-line codebase you’ll get thousands of errors — every property access where the value might be missing.
Verdict: this is where TypeScript stops being type-flavored JavaScript and starts being a real type system. Worth every minute of cleanup. The bug class it eliminates — Cannot read property 'x' of undefined — is single-handedly responsible for half the production crashes I’ve debugged in JS services.
Enable it second, after noImplicitAny is clean.
strictFunctionTypes
What it does: makes function parameter types contravariant. Most users never notice except in a few specific patterns.
type StringHandler = (s: string) => void;
type AnyHandler = (s: unknown) => void;
let h: StringHandler = (s) => console.log(s);
// strictFunctionTypes: false — allowed (unsafe)
h = (s: number) => console.log(s);
// strictFunctionTypes: true — error
h = (s: number) => console.log(s);
Cost to enable: low. Most code doesn’t hit the pattern that triggers it.
Verdict: yes. It catches a real class of bugs in callback-heavy code (event handlers, middleware, comparator functions). Cost is minimal.
strictBindCallApply
What it does: type-checks the arguments to .bind(), .call(), and .apply(). Without it, those methods accept anything.
Cost to enable: trivial. Almost no modern code uses these methods directly.
Verdict: keep on. Free win. The rare code that does use bind/call/apply is exactly the code where type-checking it matters.
strictPropertyInitialization
What it does: requires class properties to be initialized in the constructor or assigned a default.
class User {
// strictPropertyInitialization: true — error
name: string;
// Fix: initialize, allow undefined, or use definite assignment
age = 0;
email?: string;
id!: string; // tell TS "trust me, this gets set"
}
Cost to enable: medium if you use classes heavily; near-zero if you don’t.
Verdict: yes for codebases that use classes. The ! definite-assignment marker is the escape hatch for cases where a framework (ORM, DI container) assigns properties externally. Use it sparingly and intentionally.
noImplicitThis
What it does: errors when this inside a function would have type any.
function speak() {
console.log(this.name); // strict: error
}
Cost to enable: trivial. Modern code uses arrow functions and explicit this parameters.
Verdict: yes. Pre-arrow-function code occasionally lit this up; in 2023 it almost never does. Free safety.
alwaysStrict
What it does: emits "use strict" in compiled output and parses all files in strict mode.
Cost: none worth mentioning.
Verdict: yes. It’s free.
useUnknownInCatchVariables
What it does: changes catch (e) from e: any to e: unknown. You can no longer call methods on e without narrowing.
try {
somethingRisky();
} catch (e) {
// strict: e: unknown
console.log(e.message); // error
if (e instanceof Error) {
console.log(e.message); // ok
}
}
Cost to enable: medium. Every catch block has to narrow. Annoying but right.
Verdict: yes. JavaScript can throw literally anything — strings, numbers, plain objects. Treating caught values as unknown is correct. The fix is a one-line if (e instanceof Error) check.
Extras worth turning on beyond strict
The eight strict flags aren’t all of TypeScript’s safety options. Three more I always enable:
noUncheckedIndexedAccess
What it does: adds | undefined to the result of indexed access on objects and arrays whose key isn’t known statically.
const arr = ["a", "b", "c"];
// noUncheckedIndexedAccess: false
const x: string = arr[10]; // x is "string" but value is undefined at runtime
// noUncheckedIndexedAccess: true
const x: string = arr[10]; // error: type 'string | undefined' not assignable to 'string'
This catches a class of bugs that strictNullChecks alone misses — array indexing past the end, object lookups with missing keys.
Cost: medium. Every arr[i] or record[key] now needs a check or a non-null assertion.
Verdict: yes, especially for backend code. The fix is usually arr[i] ?? defaultValue or restructuring to iterate rather than index.
exactOptionalPropertyTypes
What it does: distinguishes { x?: number } (property may be absent) from { x: number | undefined } (property is present but possibly undefined).
type Opts = { timeout?: number };
const o: Opts = { timeout: undefined }; // strict + exact: error
Cost: medium-high. Lots of code passes undefined explicitly.
Verdict: yes for libraries and public APIs where the distinction matters. Optional for application code. The errors are real bugs (you didn’t want to pass undefined) about 40% of the time.
noImplicitOverride
What it does: requires the override keyword on methods overriding parent class methods.
Cost: trivial if you use classes.
Verdict: yes. It catches “I renamed the parent method and forgot to rename the override” silently.
What I actually use
For a new TypeScript backend project in 2023, my tsconfig.json compiler options:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true
},
"include": ["src/**/*"]
}
The last two — consistent casing and isolated modules — aren’t strictness but they prevent footguns with build tools and case-insensitive filesystems.
Common Pitfalls
- Disabling a strict flag instead of fixing the errors. “We turned off
strictNullChecksbecause the migration was painful” is a deferred-cost decision that compounds. - Mixing strict and non-strict files via
// @ts-nocheck. You import a non-strict file, lose the safety, and don’t notice. Migrate the file properly. - Setting
strict: truethen suppressing errors withas any. You haven’t fixed the bug; you’ve hidden it. The pattern to ban in lint. - Forgetting
noUncheckedIndexedAccessexists. It’s not instrict: true. I’ve seen serious codebases withstrict: truebutarr[i].toLowerCase()lurking everywhere. - Targeting too-old a JS version.
target: ES5is rarely needed in 2023 for backend code. ES2022 unlocks better output and many runtime checks.
Wrapping Up
Strict mode pays for itself within months. Migration projects that turn it on file-by-file are still better than projects that never get there. Next week’s post closes out February: type-safe API routes in Next.js 13, which is what strict mode plus Zod plus tRPC ergonomics buy you in practice.
If your tsconfig.json doesn’t have strict: true, you’re not using TypeScript — you’re using JavaScript with annotations. Reference: the official strict-flag documentation.