You'd think TypeScript would have made the null vs undefined problem go away. The compiler tracks both. Strict null checks have been on by default in modern projects for years. And yet — the bugs still happen.

The reason: null and undefined mean different things in JavaScript, and most APIs blur the distinction. The compiler can only protect you when the contract is honest about which one is being used.

null Means Intentional Absence

null is something you put in a field on purpose to say "there is no value here, and I know it."

TypeScript
type User = {
  id: string;
  name: string;
  manager: User | null; // explicit: this user has no manager
};

Reading user.manager === null is a meaningful answer: the relationship was checked and there isn't one. It's data with a known shape — the absence is the value.

null shows up in JSON, in databases (NULL), in deliberate "empty" states. It survives serialization. It's the right choice when "no value" is itself information.

undefined Often Means Missing Information

undefined is what you get when there isn't a value — the variable was never set, the property doesn't exist, the function didn't return anything.

TypeScript
type User = {
  id: string;
  name: string;
  email?: string; // optional: may not be in the object at all
};

const u: User = { id: '1', name: 'Alice' };
u.email; // undefined — never set

u.email === undefined doesn't tell you whether the user has no email or just wasn't asked. The shape itself is incomplete.

undefined doesn't survive JSON.stringify (it's omitted). It's the natural choice for optional fields — "this might or might not be in the payload."

Optional Chaining Is Not A Business Rule

Optional chaining (u?.manager?.email) is a syntax win that's also a habit trap. It silences undefined errors at every step but doesn't tell you which step actually failed.

TypeScript
// 🐛 silently gives undefined if any step is missing
const email = user?.manager?.profile?.email ?? 'no email';

// the user could be:
// - missing (no user)
// - present but no manager
// - present, manager set, but no profile
// - present, manager, profile, but no email

The user doesn't care that "something was undefined." They want to know "you don't have a manager assigned" or "your manager hasn't completed their profile" — different problems, different UX.

Use ?. for genuine "I just need a safe deref." When the absence carries meaning, check it explicitly and provide the right message.

Cheat-sheet table comparing null and undefined across five real situations: deleted user avatars, optional form fields, default parameters, JSON serialization, and SQL nullable columns. Each row marks which value to prefer and why.
null means empty. undefined means missing. Pick one per situation.

Contracts Should Say What Can Be Missing

The cleanest API design picks one convention and enforces it:

TypeScript
// Convention A: null for "value can be intentionally absent"
type User = {
  id: string;
  name: string;
  manager: User | null;       // can be intentionally null
  preferences: Preferences;   // never absent
};

// Convention B: undefined (optional) for "field may not be present in payload"
type CreateUserDraft = {
  id: string;
  name: string;
  email?: string;            // user might not have entered yet
};

Convention A for stored/returned data, where missing values are meaningful. Convention B for incoming/in-progress data, where fields are added over time. Avoid mixing both meanings on the same field.

The TypeScript flag exactOptionalPropertyTypes enforces this — email?: string becomes "this field is missing OR a string, but never the literal undefined." That single flag prevents the "I sent email: undefined and got a 400 from the API" class of bug.

A Quick Rule For New Code

When you're adding a field, decide:

Question Answer Use
Can the value meaningfully be "no value"? yes T | null
Might the field not be set yet? yes field?: T
Is it always present and required? yes T (no nullability)
Both possibilities? yes reconsider the design — almost always wrong

The "both" case is where bugs come from. Don't write field?: T | null unless you genuinely have three states (present-with-value, present-but-null, absent) and care about distinguishing them. You usually don't.

The Strict Flags That Help

In your tsconfig.json, three flags pay back hard:

JSON
{
  "compilerOptions": {
    "strictNullChecks": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true
  }
}
  • strictNullChecksstring doesn't include null/undefined; you have to opt in.
  • exactOptionalPropertyTypesfield?: T doesn't accept undefined as a value.
  • noUncheckedIndexedAccessarr[0] is T | undefined, not T. You have to handle the gap.

Together, they catch a class of bugs that pure strict: true misses.

Pro Tips

  1. Pick null or undefined per field — not both. The third state is rarely meaningful.
  2. Turn on exactOptionalPropertyTypes in new projects. Migrating later is annoying.
  3. ?? 'default' not || 'default' — the latter treats 0 and '' as missing.
  4. Check explicitly when absence carries meaning. ?. is a syntax tool, not a UX strategy.
  5. At the boundary, validate. The schema decides what null/undefined means before code sees it.

Final Tips

The shortest version: null and undefined are different. Pick one per field, document the choice, enforce it with exactOptionalPropertyTypes. The bugs that remain are about UX, not about syntax.

Most TypeScript codebases would benefit from spending a week tightening their nullability story. The compiler will catch a lot. Your future production logs will catch the rest.

Good luck — and may your ?. chains never hide the question that matters 👊