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.
TypeScript's strict mode (strict: true in tsconfig.json) is a meta-flag that enables a suite of individual compiler checks designed to eliminate entire categories of runtime errors at compile time. It's not a single feature but a bundle of flags — strictNullChecks, noImplicitAny, strictPropertyInitialization, strictFunctionTypes, strictBindCallApply, and useUnknownInCatchVariables — each closing a specific type-safety hole.
Without strict mode, TypeScript defaults to a permissive mode that tolerates null/undefined assignments to any type, allows implicit any for untyped variables, and skips variance checks on function parameters. This lax behavior was pragmatic for early adoption from JavaScript but creates the exact null crashes and type leaks strict mode exists to prevent.
In practice, strictNullChecks is the most impactful flag: it forces you to handle null and undefined explicitly, turning string | null into a distinct type from string. This alone eliminates the infamous "Cannot read property of undefined" crash that plagues JavaScript. noImplicitAny catches the silent any that creeps in when TypeScript can't infer a type — a common source of runtime surprises, especially for developers new to the type system. strictPropertyInitialization ensures class properties are either initialized in the constructor or marked with ?/!, preventing uninitialized property access. useUnknownInCatchVariables changes catch(e) from any to unknown, forcing you to narrow the error type before using it — a pattern that catches real-world bugs where error objects don't have the expected shape.
Strict mode is the default for new TypeScript projects, and for good reason: it's the difference between TypeScript being a glorified linter and a genuinely sound type system. Migrating a legacy project to strict mode requires incremental effort — typically starting with noImplicitAny and strictNullChecks on a per-file basis using // @ts-check or skipLibCheck for third-party types — but the payoff is measurable.
Companies like Airbnb, Lyft, and Slack have reported 30-50% reductions in production null-related bugs after enabling strict mode. The alternative is a codebase where null and undefined silently propagate through the type system, only to surface as runtime crashes in production — the exact problem TypeScript was designed to solve.
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.
{
"compilerOptions": {
// Enabling 'strict' is equivalent to enabling ALL eight flags below.
// We're spelling them out here so you can see exactly what you're getting.
"strict": true,
// --- What 'strict: true' implicitly sets ---
// 1. null and undefined are NOT assignable to other types.
// This is the single most impactful flag.
"strictNullChecks": true,
// 2. Variables can't silently become 'any' when TypeScript can't infer a type.
"noImplicitAny": true,
// 3. 'this' in functions must have an explicit type — prevents confusing runtime errors.
"noImplicitThis": true,
// 4. Class properties must be assigned in the constructor (or declared with '!').
"strictPropertyInitialization": true,
// 5. Function parameter types are checked contravariantly (prevents subtle type holes).
"strictFunctionTypes": true,
// 6. .bind(), .call(), .apply() are type-checked against the original function signature.
"strictBindCallApply": true,
// 7. Every JS file gets 'use strict' emitted at the top automatically.
"alwaysStrict": true,
// 8. Catch clause variables are typed as 'unknown' instead of 'any' (TS 4.4+).
"useUnknownInCatchVariables": true,
// Recommended companions (not part of 'strict' but work well with it)
"noUncheckedIndexedAccess": true, // array[index] returns T | undefined, not just T
"exactOptionalPropertyTypes": true // { name?: string } means string, NOT string | undefined
}
}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.
// --- WITHOUT strict mode (the dangerous default) --- // TypeScript doesn't complain here — but this function CAN return null. // Without strictNullChecks, TypeScript believes the return type is just 'User'. function findUserByIdUnsafe(userId: number) { const users = [ { id: 1, name: 'Alice', email: 'alice@example.com' }, { id: 2, name: 'Bob', email: 'bob@example.com' }, ]; // Array.find() returns T | undefined — but without strictNullChecks, // TypeScript lets you treat the result as always being T. Danger. return users.find(user => user.id === userId); } const unsafeUser = findUserByIdUnsafe(99); // User with id 99 does NOT exist console.log(unsafeUser.name); // RUNTIME CRASH: Cannot read properties of undefined // --- WITH strict mode (the safe, honest version) --- interface User { id: number; name: string; email: string; } // With strictNullChecks active, TypeScript correctly infers the return // type as 'User | undefined'. You're forced to handle both cases. function findUserById(userId: number): User | undefined { const users: User[] = [ { id: 1, name: 'Alice', email: 'alice@example.com' }, { id: 2, name: 'Bob', email: 'bob@example.com' }, ]; return users.find(user => user.id === userId); } // TypeScript now REFUSES to compile this without a null check: // const brokenUser = findUserById(99); // console.log(brokenUser.name); // ERROR: Object is possibly 'undefined' // The correct pattern — narrow the type before using it: const safeUser = findUserById(1); if (safeUser !== undefined) { // TypeScript knows safeUser is type 'User' inside this block — not 'User | undefined' console.log(`Found user: ${safeUser.name} (${safeUser.email})`); } else { console.log('No user found with that ID.'); } // --- noImplicitAny in action --- // WITHOUT noImplicitAny, TypeScript silently types 'data' as 'any'. // Type safety is completely gone for this parameter and everything downstream. function processDataUnsafe(data) { // TS would allow this without noImplicitAny return data.toUpperCase(); // Could crash if 'data' is a number } // WITH noImplicitAny — you must declare the type. No more silent escape hatches. function processData(data: string): string { // TypeScript knows this is a string — .toUpperCase() is safe and autocomplete works return data.toUpperCase(); } console.log(processData('hello, world')); // Output: HELLO, WORLD
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.
// ================================================================ // strictPropertyInitialization — Class property safety // ================================================================ class OrderService { // Without strictPropertyInitialization, you could declare these and // forget to assign them. At runtime, they'd both be 'undefined'. private apiBaseUrl: string; private maxRetries: number; constructor(apiBaseUrl: string, maxRetries: number) { // TypeScript verifies these assignments happen here. // Remove either line and you get: "Property has no initializer and // is not definitely assigned in the constructor." this.apiBaseUrl = apiBaseUrl; this.maxRetries = maxRetries; } getOrderEndpoint(orderId: string): string { // Safe — TypeScript knows apiBaseUrl is definitely a string here return `${this.apiBaseUrl}/orders/${orderId}`; } } // A class that uses lazy initialization (e.g. set by a framework after construction) class ReportGenerator { // The '!' (definite assignment assertion) tells TypeScript: // "Trust me — this WILL be assigned before it's read." // Use this sparingly and only when you're 100% sure. private templateEngine!: { render: (template: string) => string }; // Called by a framework (e.g. Angular's ngOnInit) after construction initialize(engine: { render: (template: string) => string }): void { this.templateEngine = engine; } generateReport(template: string): string { return this.templateEngine.render(template); } } // ================================================================ // useUnknownInCatchVariables — Safe error handling // ================================================================ interface ApiError { statusCode: number; message: string; } // A helper that checks if a thrown value matches our ApiError shape function isApiError(error: unknown): error is ApiError { // We check every property before trusting the shape return ( typeof error === 'object' && error !== null && 'statusCode' in error && 'message' in error ); } async function fetchOrderDetails(orderId: string): Promise<void> { try { // Simulating a fetch call that might throw const response = await fetch(`https://api.example.com/orders/${orderId}`); if (!response.ok) { // We throw a structured object, not just a string throw { statusCode: response.status, message: 'Order not found' } as ApiError; } const order = await response.json(); console.log('Order details:', order); } catch (error) { // With useUnknownInCatchVariables, 'error' is typed as 'unknown'. // TypeScript REFUSES to compile: console.log(error.message) // because it doesn't know error has a .message property. if (isApiError(error)) { // Inside this block TypeScript knows 'error' is ApiError console.error(`API Error ${error.statusCode}: ${error.message}`); } else if (error instanceof Error) { // Standard JS Error objects (network failures, etc.) console.error(`Network error: ${error.message}`); } else { // Someone threw a string, number, or something else entirely console.error('An unexpected error occurred:', String(error)); } } } // Quick demo of the OrderService const orderService = new OrderService('https://api.example.com', 3); console.log(orderService.getOrderEndpoint('ORD-4892')); // Calling fetchOrderDetails (won't actually run without a real server) // fetchOrderDetails('ORD-4892');
.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.
// ================================================================ // strictFunctionTypes — Contravariance // ================================================================ interface Animal { name: string; sound: string; } interface Dog extends Animal { breed: string; } // Without strictFunctionTypes, you could assign an 'Dog' parameter handler // to an 'Animal' handler slot — dangerous because the function might call // 'breed' on an Animal that doesn't have it. type AnimalHandler = (animal: Animal) => void; type DogHandler = (dog: Dog) => void; function useHandler(handler: AnimalHandler): void { handler({ name: 'Generic', sound: '???' }); } // This assignment is unsafe without strictFunctionTypes: // const dogHandler: DogHandler = (dog: Dog) => console.log(dog.breed); // useHandler(dogHandler); // <-- This would compile without strictFunctionTypes // // but crash at runtime because breed doesn't exist. // WITH strictFunctionTypes enabled, the assignment above is correctly rejected. // ================================================================ // strictBindCallApply — Safe invocation methods // ================================================================ function formatGreeting(greeting: string, name: string): string { return `${greeting}, ${name}!`; } // Without strictBindCallApply, these would compile but fail at runtime: // formatGreeting.call(null, 'Hello'); // Missing 'name' argument! // formatGreeting.apply(null, [42, 'World']); // Wrong type for 'greeting'! // With strictBindCallApply enabled: const correct = formatGreeting.call(null, 'Hello', 'World'); // const wrong = formatGreeting.call(null, 'Hello'); // ERROR: Expected 2 arguments, got 1 console.log(correct); // Output: Hello, World! // ================================================================ // Real-world example: Event handler binding // ================================================================ class ButtonUI { constructor(private label: string) {} onClick(event: MouseEvent): void { console.log(`${this.label} clicked at (${event.clientX}, ${event.clientY})`); } } const button = new ButtonUI('Submit'); // Without strictBindCallApply, you could accidentally pass the wrong arg: // document.addEventListener('click', button.onClick.bind(button, 'extra-arg')); // This would compile but produce a nonsensical event object. // With strictBindCallApply, it's an error: // document.addEventListener('click', button.onClick.bind(button, 'extra-arg')); // ERROR: Argument of type '[ButtonUI, string]' is not assignable to '[event: MouseEvent]' // Correct usage: document.addEventListener('click', button.onClick.bind(button));
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.
// ================================================================ // STEP 1: The problem noUncheckedIndexedAccess solves // (Not in strict bundle — add it separately) // ================================================================ const productNames: string[] = ['Laptop', 'Mouse', 'Keyboard']; // WITHOUT noUncheckedIndexedAccess: // TypeScript types productNames[5] as 'string' — but it's actually undefined at runtime const firstProduct = productNames[0]; // TypeScript says: string ✓ const missingProduct = productNames[5]; // TypeScript says: string ✗ (it's actually undefined) // console.log(missingProduct.toUpperCase()); // RUNTIME CRASH // WITH noUncheckedIndexedAccess: // TypeScript correctly types productNames[n] as 'string | undefined' // You're forced to check before using it function getProductName(index: number): string { const product = productNames[index]; // Type: string | undefined if (product === undefined) { return 'Unknown Product'; } return product; // Type narrowed to: string } console.log(getProductName(0)); // Laptop console.log(getProductName(5)); // Unknown Product // ================================================================ // STEP 2: Incremental migration pattern // ================================================================ // In files not yet migrated, add this at the very top: // // @ts-nocheck // This suppresses ALL TypeScript errors in that file. // It's a tactical retreat, not surrender — remove it file by file. // ================================================================ // STEP 3: strictFunctionTypes — a subtle but important flag // ================================================================ // This flag enforces contravariance on function parameter types. // Here's a concrete example of the bug it prevents: type StringHandler = (value: string) => void; type AnimalHandler = (animal: { name: string; sound: string }) => void; // Without strictFunctionTypes, TypeScript allowed assigning a more specific // function type to a broader one — creating a silent type hole. function processWithHandler( handler: (value: string) => void, input: string ): void { handler(input); } function uppercaseHandler(value: string): void { console.log(`Processed: ${value.toUpperCase()}`); } processWithHandler(uppercaseHandler, 'hello world'); // ================================================================ // STEP 4: A complete strict-mode tsconfig for a new project // ================================================================ /* { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitReturns": true, // Functions must always return a value "noFallthroughCasesInSwitch": true, // switch cases must have break/return "forceConsistentCasingInFileNames": true, "skipLibCheck": true } } */ console.log(getProductName(0)); console.log(getProductName(5));
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.
// io.thecodeforge — javascript tutorial // Without noImplicitAny: TypeScript infers 'any' for 'items' and 'fn' function processItems(items, fn) { return items.map(fn); // Runtime error if items isn't an array } // With noImplicitAny: Compiler screams immediately // Parameter 'items' implicitly has an 'any' type // Parameter 'fn' implicitly has an 'any' type // The fix: function processItems<T>(items: T[], fn: (item: T) => void): void { items.map(fn); // Now TypeScript validates the contract at compile time } // If you really need any, be explicit: function legacyHandler(items: any): void { // I know what I'm doing (or I'm migrating, and this is a stub) }
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.
// io.thecodeforge — javascript tutorial class PaymentHandler { private amount: number = 100; process(callback: () => void): void { callback(); } logAmount(): void { console.log(this.amount); // 'this' is PaymentHandler instance } } const handler = new PaymentHandler(); // Without noImplicitThis: This compiles, then breaks at runtime handler.process(handler.logAmount); // 'this' is undefined in strict mode // Output: TypeError: Cannot read properties of undefined (reading 'amount') // With noImplicitThis: TypeScript catches the implicit 'any' for 'this' // The fix: use an arrow function handler.process(() => handler.logAmount()); // Works correctly // Or annotate 'this' explicitly in the function signature function standaloneFunction(this: { amount: number }): void { console.log(this.amount); }
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.
// io.thecodeforge — javascript tutorial import { z } from 'zod'; // TypeScript ensures shape—Zod ensures semantics const UserSchema = z.object({ email: z.string().email(), role: z.enum(['admin', 'viewer', 'editor']) }); type User = z.infer<typeof UserSchema>; function processUser(raw: unknown): User { // Runtime validation at the boundary const user = UserSchema.parse(raw); return user; // Now fully trusted } try { const badPayload = { email: 'notanemail', role: 'superadmin' }; processUser(badPayload); } catch (err) { console.error(err.errors); // [ // { message: 'Invalid email', path: ['email'] }, // { message: "Invalid enum value", path: ['role'] } // ] }
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.
// io.thecodeforge — javascript tutorial // Step 1: Isolate worst files with @ts-nocheck // Step 2: Fix one at a time, then remove the comment // Before: any hell function fetchUser(id: any): any { return fetch(`/users/${id}`).then(r => r.json()); } // After: strict + unknown interface ApiResponse { id: string; name: string | null; email: string; } async function fetchUserStrict(id: string): Promise<ApiResponse> { const raw: unknown = await fetch(`/users/${id}`).then(r => r.json()); // Runtime check — no cheating if ( typeof raw !== 'object' || raw === null || !('id' in raw) || !('email' in raw) ) { throw new Error('Invalid API response shape'); } return raw as ApiResponse; }
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'tsc --noEmit --noImplicitAnygrep -rn 'function.*(.*)' src/ | head -20function process(data: string): void { ... }tsc --noEmit --strictNullCheckscat src/features/checkout.ts | grep -n 'callback'callback() with callback?.() and handle the case when it's undefined.tsc --noEmit --useUnknownInCatchVariablesgrep -rn 'catch' src/catch (error) { if (error instanceof Error) { console.error(error.message); } }| Flag | What It Catches | Without It, You Risk |
|---|---|---|
| strictNullChecks | null/undefined used as non-nullable types | Runtime TypeError on .property access |
| noImplicitAny | Untyped function params/variables silently typed as 'any' | Type safety silently disabled for entire code paths |
| strictPropertyInitialization | Class properties declared but never assigned | Accessing a property that's secretly undefined at runtime |
| useUnknownInCatchVariables | Accessing .message on a caught error without type checking | Crashing in your error handler — the worst place to crash |
| strictFunctionTypes | Unsafe function type assignments that create type holes | Functions receiving the wrong argument types with no error |
| strictBindCallApply | .call()/.bind()/.apply() called with wrong argument types | Type-unsafe invocations that only fail at runtime |
| noUncheckedIndexedAccess (extra) | Array/object indexing that might return undefined | Treating a potentially missing value as definitely present |
| exactOptionalPropertyTypes (extra) | Optional properties that should not be explicitly set to undefined | Accidentally setting undefined where the API expected the property to be absent |
| 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
Common mistakes to avoid
4 patternsUsing '!' (non-null assertion) everywhere to silence strictNullChecks errors
Setting strict: true in tsconfig but having a tsconfig.app.json or tsconfig.build.json that overrides it with strict: false
Typing caught errors as 'any' manually to work around useUnknownInCatchVariables
Ignoring noUncheckedIndexedAccess because 'my arrays are always complete'
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.With strictNullChecks enabled, how would you safely handle a function that might return null or undefined? Walk me through at least two patterns.
const result = getData(); if (result === null) return default;. 2) Optional chaining with fallback: const name = user?.name ?? 'Unknown';. The key is that TypeScript narrows the type within the conditional block. You can also use the nullish coalescing operator ?? to provide a default when the value is null or undefined.Why are caught errors typed as 'unknown' rather than 'Error' in strict mode, and what does that tell you about how TypeScript thinks about exception safety?
Error, you'd have a false sense of safety because the actual thrown value might not have a .message property. By typing them as unknown, TypeScript forces you to write code that actually checks what was thrown before using it. This reflects TypeScript's design philosophy: don't assume — verify. It's the same principle that makes strictNullChecks so powerful: honest types prevent silent assumptions.What is the definite assignment assertion `!` and when is it appropriate to use it?
! operator tells TypeScript 'this value is definitely not null/undefined at this point' or 'this property will be assigned before it's read'. Appropriate uses: 1) Class properties set by a dependency injection framework after instantiation. 2) Variables that are assigned inside a callback that runs synchronously (e.g., let result!: string; setTimeout(() => result = 'done', 0); — but be careful with async). 3) When you have a guard upstream that TypeScript can't reason about. Inappropriate uses: as a lazy workaround for real nullable logic. Overusing ! erodes the safety strict mode provides.Frequently Asked Questions
No. Strict mode is entirely a compile-time feature. It only changes what the TypeScript compiler checks and rejects — the JavaScript it emits is identical whether strict is on or off. All the type information is erased during compilation.
Yes — but do it incrementally. Enable 'strict: true' immediately, then add '// @ts-nocheck' to every file that breaks. Remove the suppression comment file by file as you work through the codebase. This gives you the safety net on new code right away without blocking the migration.
With 'any', TypeScript lets you access any property without checks — it trusts you completely. With 'unknown', TypeScript refuses to let you access any property until you prove the type via a type guard or instanceof check. 'unknown' is the safe default: it forces you to handle the possibility that the thrown value isn't what you expect.
Technically no — strict is a project-wide compiler option. However, you can simulate per-file strictness by adding '// @ts-nocheck' at the top of files you haven't fixed yet. Those files are effectively ignored, and new code in other files benefits from full strict checking. This is the recommended migration approach.
Only during an incremental migration of a very large, untyped codebase where fixing all issues at once would block development for weeks. Even then, you should still enable strict at the project level and suppress old files. For new projects, there is no valid reason to disable it.
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