TypeScript has a strict flag, but "strict" is a shorthand for a bundle of individual options that each catch a different class of bug. Most teams toggle "strict": true in tsconfig.json and move on. That is the right call — but it helps to understand what you are actually turning on, which flags have the highest signal-to-noise ratio, and where the remaining gaps are that strict mode does not close.
what "strict": true actually enables
strict is shorthand for seven compiler options as of TypeScript 5.x. They are individually togglable, but the bundle is the right default for new projects:
strictNullChecks— probably the most impactful single flag in the whole language. Without it,nullandundefinedare assignable to every type, so the compiler cannot catch the classic "cannot read properties of null" runtime crash. With it, they become their own types, and the compiler forces you to handle them explicitly.noImplicitAny— forbids implicitanyinferences. When TypeScript cannot determine a type and you have not provided one, it falls back toany, silently opting that variable out of type checking. With this flag, that becomes a compile error.strictFunctionTypes— enables contravariant checking of function parameters. Without it, assigning a function that expects a wider type where a narrower one is required silently compiles. This catches a category of subtle callback type bugs that are otherwise invisible.strictBindCallApply— correctly typesbind,call, andapply. Without it, all three returnany, which is one of the easiest ways to accidentally escape the type system at a call site.strictPropertyInitialization— ensures class properties are either initialized in the constructor or declared as possibly undefined. This catches the pattern of declaring a property and assuming it gets set somewhere else, only to readundefinedat runtime.noImplicitThis— disallows implicitanyforthis. If TypeScript cannot determine the type ofthisin a function, this flag forces you to annotate it explicitly.alwaysStrict— parses source files in strict mode and emits"use strict"in every output file. Largely a formality in modern module-based codebases, but worth having consistently.
the flag strict does not include but should
One setting missing from the strict bundle that belongs in almost every project: noUncheckedIndexedAccess.
By default, when you read from an array or object by index — arr[0], map[key] — the result is typed as T, not T | undefined. This is a lie: the array might be empty, and the key might not exist. noUncheckedIndexedAccess widens those types to T | undefined, which is honest. It produces a lot of errors in existing code because the pattern of reading an index without checking is everywhere, but each error represents a real potential crash. Add it alongside strict on new projects, and add it incrementally on existing ones.
how strictNullChecks changes the way you write code
Before strictNullChecks, TypeScript's type system is effectively opt-in, because null and undefined can silently appear anywhere. After it, you actually have to reason about whether a value is present before using it. This has a few practical effects.
It makes you reach for optional chaining (?.) and nullish coalescing (??) as requirements rather than style preferences. user?.profile?.avatar ?? defaultAvatar stops being syntactic sugar and starts being a necessary expression of the type — because the types say those fields may be absent.
It makes you confront the return types of browser APIs. document.getElementById returns HTMLElement | null. Array.prototype.find returns T | undefined. You cannot pretend otherwise. This friction is the point: these really can be null, and the compiler is saving you from assuming they are not.
It also makes you write more accurate types for your own functions. A function that might not find a record should return User | null, not User. A function that takes an optional argument should declare it as string | undefined, not leave it typed as string and hope callers behave.
migrating an existing codebase incrementally
Turning on strict all at once in a mature codebase typically produces hundreds of errors, which is demoralising and hard to review. The better path is incremental migration.
The order that produces the least thrash:
- Enable
noImplicitAnyfirst. This forces you to add type annotations to the most loosely typed parts of the codebase. The errors are easy to fix — add a type — and the fixes make the next step easier. - Enable
strictNullChecksnext. This produces the most errors but they follow recognisable patterns: DOM query results, optimistic API response types, constructor-initialized class properties. Most fixes are mechanical: add| null, add a guard, use?.. - Enable the remaining strict flags. By this point the codebase is clean enough that these produce relatively few new errors.
Using // @ts-ignore and the non-null assertion operator (!) as temporary escapes during migration is acceptable. Track them — grep for them in CI — and treat reducing the count as an ongoing goal, not as a permanent state.
For very large codebases, two-tsconfig migration is useful: a tsconfig.strict.json that extends the base and applies strict flags to a subset of paths (new code, recently touched files), and the original config for legacy code. TypeScript's include and exclude fields let you grow the strict surface area incrementally without blocking the rest of the team.
what strict mode still does not catch
Strict mode significantly narrows the gap between what TypeScript allows and what is actually correct. But there are real limits worth knowing.
Runtime shapes are not verified. If you fetch data from an external API and cast it to a type with as, TypeScript trusts you. The actual data might not match. This is the most common source of production type errors in TypeScript codebases. The solution is validation at the boundary: libraries like Zod, Valibot, or ArkType parse and validate external data and produce typed results that the compiler can trust for the rest of the execution path.
Mutation is not tracked by default. An array typed as string[] can be mutated in place and the compiler will not object. Using readonly arrays (ReadonlyArray<string> or readonly string[]) and Readonly<T> on objects is the right habit, but it requires discipline — the strict bundle does not enforce it.
Type narrowing has limits. TypeScript's control flow analysis is impressive but cannot follow narrowing through external functions or across complex async boundaries. If you check that a value is non-null and then pass it to a function that assigns null to the outer variable, TypeScript does not see the connection. Discriminated unions and type predicates (is) make narrowing more reliable, but they require deliberate design.
Enums are still rough. TypeScript's numeric enums have well-documented footguns — any number is assignable to a numeric enum, which defeats the purpose. Prefer const enums for inlining, or better yet, string literal unions (type Direction = 'north' | 'south' | 'east' | 'west') which are narrower, serializable, and carry no surprises.
a baseline tsconfig worth starting from
Here is a configuration that goes slightly beyond strict and represents a solid starting point for new TypeScript projects in 2026:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"skipLibCheck": true
}
}
The extra flags beyond strict:
noUncheckedIndexedAccess— honest array and object index access, as discussed above.noImplicitOverride— requires theoverridekeyword when a subclass method shadows a base class method. Catches the silent no-op that happens when a base class method gets renamed and the subclass implementation stops overriding anything.exactOptionalPropertyTypes— distinguishes between a property being absent ({}) and being explicitly set to undefined ({ key: undefined }). This is a subtle distinction with real implications for code that checkskey in objor uses spread operators.noFallthroughCasesInSwitch— flags switch cases that fall through to the next case without abreakorreturn. Intentional fallthrough is rare; accidental fallthrough is a real bug class.skipLibCheck: true— skips type-checking third-party.d.tsfiles. Type errors in library declarations are almost never your problem to fix, and checking them adds meaningful compilation time with no benefit to your codebase's correctness.
The goal of a good tsconfig is not the strictest possible configuration — it is the strictest configuration you can maintain and enforce consistently. A codebase with "strict": true, zero suppressions, and boundary validation on external data is worth far more than one with every flag enabled and a hundred @ts-ignore comments papering over the gaps.