Strict Mode Null Crash - strictNullChecks Prevents It
StrictNullChecks disabled caused 'Cannot read properties of null' on checkout.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Strict mode enables eight compiler flags that catch null dereferences, implicit any, unsafe function types, and uninitialized class properties.
- The two most impactful flags: strictNullChecks and noImplicitAny — they catch ~90% of real-world type bugs.
- For greenfield projects, enable strict:true from the first commit. For legacy codebases, enable it globally, suppress with @ts-nocheck, then fix files incrementally.
- Performance impact: zero runtime cost. Strict mode is a compile-time guard that emits identical JavaScript.
- Production insight: A missing strictNullChecks flag caused an e-commerce checkout crash — the code compiled fine but crashed when a user profile was null.
Imagine you're writing a letter and you hire two proofreaders. The first one only flags spelling mistakes. The second one flags spelling, grammar, missing words, ambiguous sentences, AND tells you when something you wrote could be misunderstood. TypeScript's strict mode is that second proofreader — it doesn't just check the basics, it checks everything that could quietly cause a problem later. Without it, TypeScript is polite. With it, TypeScript is honest.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Most TypeScript developers enable strict mode on day one and never think about it again — and that's exactly the problem. When something eventually breaks because of an implicit any or an unchecked null, they have no idea why their 'typed' code behaved exactly like untyped JavaScript. Strict mode isn't just a setting you toggle; it's a philosophy about how seriously you take type safety. It's the difference between TypeScript as a linter and TypeScript as a genuine safety net.
TypeScript was designed to be gradually adoptable, which means its default settings are deliberately lenient. You can migrate a giant JavaScript codebase to TypeScript without changing a single line of logic — which sounds great until you realise you've just added TypeScript syntax without TypeScript's most valuable protections. The problems strict mode prevents — null dereferences, unintended any leakage, implicit function return types — are precisely the bugs that take hours to track down in production.
By the end of this article you'll understand exactly what each flag inside strict mode does, why each one was created, and how to use them in a real project. You'll be able to read a tsconfig.json and immediately know how protected that codebase really is — which is a skill that genuinely separates intermediate TypeScript developers from advanced ones.
What 'strict: true' Actually Enables Under the Hood
Setting 'strict: true' in your tsconfig.json is a shorthand. It doesn't flip one switch — it flips eight. Understanding each one individually is critical because when you migrate a legacy project you'll often need to turn them on one at a time, and when an error appears you need to know which rule it's coming from.
The eight flags that 'strict: true' enables are: strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitAny, noImplicitThis, alwaysStrict, and useUnknownInCatchVariables (added in TypeScript 4.4).
Each one was added in response to a real category of bugs that people kept shipping. They're not academic — they're battle scars. The two you'll feel immediately are noImplicitAny and strictNullChecks. Those two alone will surface more latent bugs in a typical codebase than everything else combined.
You can verify which flags are active at any time by running 'tsc --showConfig' in your terminal. It prints the fully resolved config including all inherited defaults, so there's no ambiguity about what's actually running.
strictNullChecks and noImplicitAny: The Two Flags That Change Everything
These two flags deserve their own section because they're responsible for the vast majority of bugs strict mode catches. Without strictNullChecks, null and undefined can be assigned to any variable of any type — which is exactly how JavaScript works, and exactly why JavaScript is so error-prone.
Consider a function that fetches a user from a database. Without strictNullChecks, you can type the return value as 'User' even though the function clearly might return null when no user is found. TypeScript won't complain. Every caller of that function then assumes they have a User object and calls .name or .email on it — until one day a user isn't found and the whole thing crashes at runtime.
noImplicitAny closes a different gap. TypeScript infers types from usage, but when it genuinely can't infer — like an untyped function parameter — it has two choices: error, or silently assign 'any'. Without noImplicitAny it chooses silence. That silence erases type safety from that point forward in your code, like a hole in a net.
These two flags work together. strictNullChecks narrows the universe of values a type can hold. noImplicitAny ensures that universe is actually defined. Both are essential.
Array.find(), Map.get(), and any DOM query (document.getElementById) all return T | undefined or null. These are the most common sources of null-related crashes. With strictNullChecks on, TypeScript forces you to handle the undefined case every single time. That's not inconvenient — that's the whole point.user?.email and nullish coalescing: user?.email ?? 'fallback'. The type stays string | undefined.user!.email. Better: restructure the code to use a real guard.! on the property declaration, but ensure it's actually assigned before first use.strictPropertyInitialization and useUnknownInCatchVariables in Practice
These two flags are less talked about but protect against genuinely nasty bugs.
strictPropertyInitialization ensures that every property declared in a class is actually assigned a value — either in its declaration or in the constructor. Without it, you can declare a property as 'string' but never assign it, and TypeScript won't warn you. At runtime, accessing that property returns undefined — which breaks your string assumption silently.
The fix is always one of three things: assign it inline, assign it in the constructor, or add the definite assignment assertion (!) if you genuinely know it'll be assigned before use (like by a dependency injection framework).
useUnknownInCatchVariables was added in TypeScript 4.4 and solves a real problem: error handling. Before this flag, catch clause variables were typed as 'any'. That meant you could write 'error.message' without TypeScript complaining — even if the thrown value was a string, a number, or some custom object with no .message property. With this flag, caught errors are typed as 'unknown', forcing you to check the type before using any properties. It's annoying for five minutes, then it saves you hours.
.message property. When a null was thrown (yes, you can throw null), the error handler itself crashed.! assertion: private foo!: string;. Then ensure the assignment happens before any read.? optional: private foo?: string;. It defaults to undefined, which is safe.strictFunctionTypes and strictBindCallApply: Two Flags Against Subtle Type Holes
These two flags protect against more subtle type errors that often fly under the radar. They're harder to explain than null checks, but they've caught real bugs in real systems.
strictFunctionTypes enforces contravariance on function parameter types. In plain terms: if you have a function that accepts a 'Dog', TypeScript won't let you pass it where a function accepting 'Animal' is expected. Without this flag, you could accidentally do the reverse — assign a more specific function to a broader parameter type — creating a type hole that could slip through.
strictBindCallApply ensures that when you use .bind(), .call(), or .apply(), TypeScript verifies the argument types against the original function signature. Without it, you could pass the wrong number or type of arguments, and the error only appears at runtime.
Both flags are part of the strict bundle and should stay enabled. They prevent category errors that are extremely hard to debug because they only manifest when specific argument types are passed at runtime.
Migrating a Real Project to Strict Mode Without Breaking Everything
The biggest practical question isn't 'what does strict mode do?' — it's 'how do I turn it on in a codebase that didn't start with it?'
The naive approach is to add 'strict: true' and try to fix every error. In a large codebase this can surface hundreds of errors at once, which is demoralising and creates enormous PRs that are hard to review. There's a better strategy.
TypeScript supports per-file override via 'ts-ignore' and the newer '// @ts-nocheck' comment. The migration pattern that actually works in production: enable strict at the project level, immediately suppress all errors in existing files using @ts-nocheck, then remove the suppression comment from each file you touch during normal feature work. The codebase gets safer every sprint without ever blocking progress.
For greenfield projects, there's no excuse — strict: true should be in your tsconfig from the very first commit. The cost of enabling it later grows quadratically with codebase size. CRA, Vite, and Next.js all scaffold with strict mode on by default now, which is the right call.
Two flags worth adding beyond strict: 'noUncheckedIndexedAccess' (array indexing returns T | undefined) and 'exactOptionalPropertyTypes' (truly optional vs. explicitly undefined). Neither is in the strict bundle but both catch real bugs.
T | undefined at every index access.undefined to an optional property without ?.noImplicitAny: The Leaky Abstraction That Burns Junior Devs at 2 AM
TypeScript's whole job is to catch type mismatches before they hit production. noImplicitAny is the front door of that promise. Without it, TypeScript silently gives up on variables it can't infer — hammering any into the type and moving on.
The problem isn't that any exists. The problem is that when TypeScript assumes any for you, you don't even know it's happening. A function parameter without a type annotation? That's any. A return value from a third-party lib that isn't typed? That's any. Suddenly your supposedly type-safe codebase is just JavaScript with extra steps.
Why does this matter? Because any disables type checking entirely. You can pass a string where an array is expected, and TypeScript holds your beer. The first time you get undefined is not a function from a .map() call on what you thought was an array, you'll remember this section.
Enable noImplicitAny. Explicitly type every function parameter, every return, every edge case. If you genuinely need any, spell it out — own that decision.
any unless you enable noImplicitAny. Always run npm install @types/pkg or use skipLibCheck: true carefully.noImplicitThis: The Silent Killer of Class Context
JavaScript's this is a shape-shifting nightmare. In a class method, this should refer to the class instance — until you pass that method as a callback or strip it off its object. Without noImplicitThis, TypeScript doesn't care. It assumes this is any, and you get runtime this errors that take hours to debug.
noImplicitThis forces you to annotate this when it's ambiguous. This is your early warning system for callback-related context loss. When you see a function that uses this but isn't a method of a class, TypeScript will demand an explicit type annotation like this: YourType.
The payoff is brutal honesty. You either fix the context by using arrow functions or .bind(), or you document that the function expects a specific this context. Either way, the bug surfaces during compilation, not during a customer demo.
Turn it on. It's one flag that saves you from the worst class of JavaScript bugs — the ones that only happen under specific call patterns and are nearly impossible to reproduce.
this in callbacks. Those are your runtime bombs. Convert them to arrow functions or .bind() before enabling noImplicitThis.this or lose it. There is no middle ground in strict TypeScript.Strict Mode Won't Save You From Business Logic Bugs — Here's What Will
Junior devs think strict mode is a magic shield. It's not. It catches type mismatches, null references, and accidental anys. But it won't stop you from passing a valid User object with the wrong role field. That's a business logic bug, and TypeScript stays silent.
Strict mode enforces structural correctness — your types match. It does not enforce semantic correctness — your data makes sense. The gap between a string and a valid email address is infinite. No amount of strict: true closes that.
Production code needs both: strict mode for the compiler, runtime validation for the real world. Use Zod or io-ts at the boundaries. Validate API responses. Validate user input. Treat strict mode as the floor, not the ceiling. Your 2 AM pager will thank you.
Enabling Strict Mode Mid-Project Is a Betrayal — But Here's How to Survive
You inherit a codebase with 50,000 lines of JavaScript pretending to be TypeScript. You flip strict: true. Suddenly, 1,400 red squiggly lines appear. Your team panics. The correct response is not to revert — it's to isolate the pain.
Add // @ts-nocheck at the file level for the worst offenders. Then work through files one by one. Start with the files that touch external APIs — these are your highest risk. Replace any with unknown first, then narrow. Fix null checks in data transformations.
CI pipeline? Add an eslint rule that bans // @ts-ignore but allows // @ts-expect-error with a reason. Track strict-mode coverage as a build metric. Every week, your threshold increases. This is how you migrate a battleship without sinking it.
The Null-Pointer Crash That Strict Mode Would Have Caught
getUser(id: number): User without any nullable indication.strictNullChecks was disabled. The function actually returned User | null for legacy users, but TypeScript allowed the caller to treat it as always User. The null slipped through because no one checked the return value.strictNullChecks. Changed the return type to User | null and updated all callers to handle the null case with early returns or fallback logic. Added a unit test that reproduces the legacy user path.- Never trust implicit null handling. Always enable strictNullChecks — it catches nulls at compile time that otherwise crash at runtime.
- When migrating a legacy codebase, fix the null-safety issues first. They're the most common source of silent production failures.
- Run
tsc --showConfigin CI to ensure the build matches local compiler settings.
tsc in CI fails with strict mode errors.tsc --showConfig to verify the resolved tsconfig. CI often uses a different tsconfig.build.json or overrides strict: false. Align all configs.array[index].noUncheckedIndexedAccess: true to your config. It forces you to handle T | undefined when indexing into arrays and objects.catch blocks complain about accessing .message.useUnknownInCatchVariables (part of strict), the error is unknown. Use error instanceof Error to narrow, or write a custom type guard.! if it's set after construction (e.g., by a DI framework)..bind().strictBindCallApply. It catches mismatches between the function signature and the arguments passed to .bind(), .call(), .apply().tsc --noEmit --strictNullCheckstsc --showConfig | grep strictNullChecksif (user) { around the usage, or set a default: const email = user?.email ?? 'unknown'| File | Command / Code | Purpose |
|---|---|---|
| tsconfig.json | { | What 'strict |
| UserLookup.ts | function findUserByIdUnsafe(userId: number) { | strictNullChecks and noImplicitAny |
| OrderService.ts | class OrderService { | strictPropertyInitialization and useUnknownInCatchVariables |
| FunctionTypeSafety.ts | interface Animal { | strictFunctionTypes and strictBindCallApply |
| MigrationStrategy.ts | const productNames: string[] = ['Laptop', 'Mouse', 'Keyboard']; | Migrating a Real Project to Strict Mode Without Breaking Eve |
| ImplicitAnyTrap.js | function processItems(items, fn) { | noImplicitAny |
| ImplicitThisBug.js | class PaymentHandler { | noImplicitThis |
| validateBoundary.js | const UserSchema = z.object({ | Strict Mode Won't Save You From Business Logic Bugs |
| migrateStrict.ts | function fetchUser(id: any): any { | Enabling Strict Mode Mid-Project Is a Betrayal |
Key takeaways
Interview Questions on This Topic
What's the difference between enabling 'strict: true' and manually listing all the strict flags individually in tsconfig? When would you choose one over the other?
strict: true is shorthand. It enables all eight flags at once. Manually listing them allows you to exclude specific flags that might be too disruptive during a migration. For example, you could enable noImplicitAny and strictNullChecks but leave strictFunctionTypes off until you've fixed callback-heavy areas. However, for greenfield projects, always use strict: true — it's simpler and ensures you get all protections.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's TypeScript. Mark it forged?
6 min read · try the examples if you haven't