I've debugged more null reference errors than I care to remember. Most of them could have been caught at compile time if the project had simply enabled TypeScript's strict mode. When you're building production systems that handle real customer data, you can't afford to let preventable bugs slip through. That's why I'm saying this plainly: turn on strict mode in every TypeScript project you start today.
strict: true in every new project
The first thing I do when initializing a new TypeScript project is open tsconfig.json and set "strict": true. This single configuration flag enables seven compiler options that together form a safety net. Without it, TypeScript becomes almost pointless—you're writing types that the compiler doesn't actually enforce.
Here's what strict mode actually enables:
noImplicitAny– variables must have declared types or inferred typesnoImplicitThis–thiscontext must be explicitalwaysStrict– code runs in strict modestrictNullChecks– null and undefined are explicit typesstrictFunctionTypes– function parameter types are strictly checkedstrictBindCallApply– bind, call, and apply are type-safestrictPropertyInitialization– class properties must be initialized
I've seen teams ship bugs to production because they assumed a value would always exist, or they let a function parameter drift into implicit any. Strict mode stops that. It forces you to think about edge cases at development time, not debug time.
Here's a minimal tsconfig.json setup:
{
"compilerOptions": {
"strict": true,
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}Once you add "strict": true, your codebase becomes dramatically more reliable. You're no longer betting on runtime behavior—the compiler becomes your first line of defense.
Catches null and undefined bugs
The single biggest source of runtime errors I've encountered is the null reference error. A function returns something that could be null, the code assumes it isn't, and hours later a customer's transaction fails in production. With strict mode, that scenario becomes impossible.
Let's say you're building a user service in a NestJS application:
// Without strict mode – this compiles
interface User {
id: number;
name: string;
email: string;
}
async function getUser(id: number) {
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
return user;
}
// Somewhere else in your code
const user = await getUser(123);
console.log(user.name); // What if user is null?Without strict mode, this code compiles perfectly. But at runtime, if the query finds no user, user is null, and you crash with "Cannot read property 'name' of null." I've fixed this bug in three different codebases.
With strictNullChecks enabled (part of strict mode), the same code becomes:
interface User {
id: number;
name: string;
email: string;
}
async function getUser(id: number): Promise {
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
return user;
}
// Compiler error! user might be null
const user = await getUser(123);
console.log(user.name); // TS2339: Property 'name' does not exist on type 'null'
// You must handle the null case
const user = await getUser(123);
if (user) {
console.log(user.name); // Safe
} else {
console.log('User not found');
}This is non-negotiable. When you're building e-commerce platforms or trading systems where money is involved, null checks aren't optional—they're mandatory. Strict mode makes them syntactically required.
I've also caught bugs in Promise handling. Consider this real scenario from a ride-hailing system I built:
// Without strict mode – passes compilation
interface Ride {
id: string;
driverId: string;
status: 'pending' | 'accepted' | 'completed';
}
function getRideStatus(rideId: string) {
const ride = rides.find(r => r.id === rideId);
return ride;
}
const ride = getRideStatus('ride-123');
const driver = driverCache.get(ride.driverId); // ride could be undefined!With strict mode, this fails at compile time. With strict mode disabled, it silently fails at 2 AM when a driver is about to accept a ride.
noUncheckedIndexedAccess for safer arrays
Array access is another vector for null bugs. When you write array[index], you're making an assumption that the index exists. In a real application, that assumption often breaks. That's where noUncheckedIndexedAccess comes in.
This isn't part of the base strict mode set, but I enable it in every project. Here's why:
// Without noUncheckedIndexedAccess
const items = ['apple', 'banana', 'orange'];
const first = items[0]; // Typed as string
const last = items[10]; // Also typed as string! But undefined at runtime
console.log(first.toUpperCase()); // Safe
console.log(last.toUpperCase()); // Runtime error: Cannot read property 'toUpperCase' of undefinedWith noUncheckedIndexedAccess enabled in tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}Now the same code forces you to be explicit:
const items = ['apple', 'banana', 'orange'];
const first = items[0]; // Typed as string | undefined
const last = items[10]; // Typed as string | undefined
// Compiler error without a check
console.log(first.toUpperCase()); // TS2339
// You must check first
if (first !== undefined) {
console.log(first.toUpperCase()); // Safe
}I added this flag to a CRM platform I was maintaining, and it caught seven potential bugs in a single code review. Seven. The fix is simple—check before accessing—but without the flag, those bugs slip through.
Here's a practical example from a travel booking system:
interface FlightOption {
airline: string;
price: number;
departureTime: string;
}
function displayCheapestFlight(flights: FlightOption[]) {
const cheapest = flights.sort((a, b) => a.price - b.price)[0];
// Without noUncheckedIndexedAccess, this types cheapest as FlightOption
// But what if flights is empty?
return `${cheapest.airline} at ${cheapest.price}`; // Runtime error possible
}
// With noUncheckedIndexedAccess, cheapest is FlightOption | undefined
function displayCheapestFlight(flights: FlightOption[]) {
if (flights.length === 0) {
return 'No flights available';
}
const cheapest = flights.sort((a, b) => a.price - b.price)[0];
return `${cheapest.airline} at ${cheapest.price}`; // Safe
}Migrate old projects gradually
If you're working with a legacy codebase, you can't just flip the strict flag and expect everything to compile. I've tried. It's painful. But gradual migration is absolutely doable, and I've done it on multiple projects.
The strategy is simple: enable strict mode in tsconfig.json, but then incrementally fix files rather than the entire codebase at once.
Step one, create a separate configuration for strict checking:
// tsconfig.strict.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true
},
"include": ["src"]
}Step two, run the strict compiler to see the full scope:
tsc --project tsconfig.strict.json --noEmitStep three, migrate file by file. I usually start with utility functions and domain logic—the files that don't depend on many other files.
Here's a before/after example from a SaaS platform I migrated:
// Before – no strict mode
function calculateDiscount(user, product) {
const discountRate = user.tier === 'premium' ? 0.2 : 0.1;
const discountedPrice = product.price * (1 - discountRate);
return discountedPrice;
}
// After – strict mode
interface User {
id: string;
tier: 'free' | 'premium' | 'enterprise';
}
interface Product {
id: string;
price: number;
name: string;
}
function calculateDiscount(user: User, product: Product): number {
const discountRate = user.tier === 'premium' ? 0.2 : 0.1;
const discountedPrice = product.price * (1 - discountRate);
return discountedPrice;
}For each file you migrate, update imports, add type annotations, handle null cases, and commit. I typically aim to migrate one or two files per day on a legacy project. After a few weeks, the entire codebase runs in strict mode, and your bug rate drops noticeably.
One helpful trick: use TypeScript's // @ts-ignore comment strategically on files you haven't migrated yet. This lets strict mode coexist with legacy code during the transition period.
Common mistakes
I've seen teams enable strict mode but then immediately work around it. Here are the most common mistakes:
- Using any to escape strict checks – I see
as anyor: anysprinkled through code to silence compiler errors. That defeats the entire purpose. If you're reaching for any, stop and think about what the type should actually be. - Optional chaining instead of null checks –
user?.emailis helpful for defensive programming, but it's not a substitute for actually validating data. If your API contract says email exists, check that it does. - Enabling strict mode but ignoring the errors – I've seen projects add
"strict": truebut leave 200 compiler errors. That's worse than not enabling it, because now developers learn to ignore the compiler. - Not enabling noUncheckedIndexedAccess – This one's sneaky. Strict mode doesn't enable it by default, but array bugs are common. Enable it explicitly.
- Forgetting to handle both null and undefined – With strict mode, both are distinct. You need to handle cases where a value is null, undefined, or both. Check carefully.
Key takeaways
- Add
"strict": trueto every new TypeScript project from day one. There's no good reason not to. - Strict mode catches null and undefined errors at compile time, not 2 AM in production. It's worth the upfront discipline.
- Enable
noUncheckedIndexedAccessalongside strict mode to prevent array access bugs. - For legacy projects, migrate gradually by fixing one file at a time. It's not a single-day job, but it's worth the time investment.
- Don't use
anyoras anyto escape strict checks. If you're reaching for it, you need to think about your types more carefully. - Treat compiler errors as real errors, not suggestions. Your future self will thank you.






Comments
Loading comments…