Your Type Check Passed. Your App Crashed Anyway.

You flipped strict: true in tsconfig.json. You fixed every red squiggle. You shipped to production confident that null and undefined were solved problems.

Then a backend response changed shape, a DOM query returned nothing, and user.profile.name threw Cannot read properties of null in the exact code TypeScript told you was safe. What happened?

TypeScript’s strict null checks are a compile-time contract. They verify that the types you wrote are internally consistent. They do not verify that the data arriving at runtime matches those types. The gap between those two things is where most production null crashes live.

Where strictNullChecks Actually Helps

When you enable strictNullChecks, TypeScript treats null and undefined as distinct types that must be handled explicitly.

// Without strictNullChecks, this compiles. With it, you get a type error.
function greet(name: string) {
  console.log(name.toUpperCase())
}

greet(null) // Error: Argument of type 'null' is not assignable to parameter of type 'string'.

This is genuinely useful. It catches the nulls you introduce in your own code: uninitialized variables, missing return values, forgotten default cases.

The catch is that it only works on code TypeScript can see at compile time. Once your app is running, TypeScript is gone. The type system evaporates. What remains is plain JavaScript, and plain JavaScript will happily let null flow through any hole you left open.

The Four Runtime Gaps strictNullChecks Cannot Close

1. API Responses Pretend to Match Your Types

You typed your API response. TypeScript believed you.

interface User {
  id: number
  profile: {
    name: string
    avatar: string
  }
}

async function fetchUser(): Promise<User> {
  const res = await fetch('/api/user')
  return res.json() as User // TypeScript trusts this cast. The backend does not.
}

const user = await fetchUser()
console.log(user.profile.name) // Crashes if profile is null

The as User assertion tells TypeScript to stop asking questions. It does not validate the payload. If the backend returns { id: 1, profile: null }, TypeScript compiled your code against a lie. The crash is real.

2. DOM Queries Return null by Design

const button = document.getElementById('submit')
button.addEventListener('click', handleClick) // Crashes if the element is missing

TypeScript knows getElementById returns HTMLElement | null. In strict mode, it forces you to handle the null case. Many developers work around this with a non-null assertion:

const button = document.getElementById('submit')!

The ! is a promise to TypeScript that you know better. When you’re wrong, the crash lands in production.

3. noUncheckedIndexedAccess Is Off by Default

Even with strict: true, TypeScript does not flag array or object index access as potentially undefined.

const users: User[] = []
const first = users[0]
first.name // No type error, but first is undefined at runtime

The same applies to records:

const cache: Record<string, string> = {}
const value = cache['missing'] // Type: string. Runtime value: undefined.

You need noUncheckedIndexedAccess: true in your tsconfig.json to close this hole. Most teams never turn it on because it makes everyday code noisier. The noise is the point.

4. any and Type Assertions Disable Everything

const data: any = JSON.parse(raw)
const user = data.user as User // All null checks are now voluntarily disabled

Every any or as in your codebase is a door you left unlocked. TypeScript stops at the threshold. What walks through is your responsibility.

What Actually Works at Runtime

TypeScript’s job is to catch mistakes in the code you write. Runtime safety requires a second layer of defenses.

Validate at the Boundary

Use a schema library like Zod to validate external data before it enters your typed system.

import { z } from 'zod'

const UserSchema = z.object({
  id: z.number(),
  profile: z.object({
    name: z.string(),
    avatar: z.string().optional(),
  }).nullable(),
})

const res = await fetch('/api/user')
const raw = await res.json()
const user = UserSchema.parse(raw) // Throws if the shape is wrong

// user.profile is typed as { name: string; avatar?: string } | null
if (user.profile) {
  console.log(user.profile.name) // Safe
}

The cost is a runtime validation step at every boundary. The benefit is that your types are no longer wishful thinking. They are enforced.

Use noUncheckedIndexedAccess

Turn it on. Accept the friction.

// With noUncheckedIndexedAccess
const first = users[0] // Type: User | undefined
if (first) {
  console.log(first.name) // Safe
}

Yes, it adds checks everywhere you index. That is exactly what you want if you are serious about null safety.

Default to Optional Chaining, Not Non-Null Assertions

// Dangerous
const name = user.profile!.name

// Safe
const name = user.profile?.name ?? 'Anonymous'

Optional chaining short-circuits on null or undefined. The nullish coalescing operator provides a fallback. This pattern is defensive by default and explicit about what happens when data is missing.

Keep any Out of Your Data Flow

Treat any like a toxic spill. If you must use it, confine it to the smallest possible scope and validate the output immediately.

// Bad: any propagates
const data: any = JSON.parse(raw)
processData(data)

// Better: validate and narrow immediately
const parsed = JSON.parse(raw) as unknown
const data = UserSchema.parse(parsed)
processData(data)

unknown forces you to prove the shape before TypeScript lets you use it. It is any with consequences.

Why Teams Skip These Defenses

Most teams do not skip them out of ignorance. They skip them because the safer patterns add friction to every data access, every API call, every DOM query. The code gets longer. The types get noisier. The temptation to slap a ! or an as on the problem grows with every deadline.

The alternative is production crashes that TypeScript promised you would not have. The compiler kept its end of the bargain. It checked your code against the types you provided. It cannot check your code against reality.

The Fix Is Two Layers, Not One

strictNullChecks is layer one. It catches the nulls you introduce yourself. Layer two is everything else: schema validation, defensive indexing, optional chaining, and the discipline to avoid any.

If your TypeScript app is still crashing with nulls, the compiler is not failing you. The gap between your types and your runtime data is. Close that gap at the boundaries, turn on noUncheckedIndexedAccess, and stop trusting backends to match your interfaces. TypeScript will still be strict. But at least your types will describe the world as it actually arrives.