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."
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.
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.
// 🐛 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.
Contracts Should Say What Can Be Missing
The cleanest API design picks one convention and enforces it:
// 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:
{
"compilerOptions": {
"strictNullChecks": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true
}
}
strictNullChecks—stringdoesn't includenull/undefined; you have to opt in.exactOptionalPropertyTypes—field?: Tdoesn't acceptundefinedas a value.noUncheckedIndexedAccess—arr[0]isT | undefined, notT. You have to handle the gap.
Together, they catch a class of bugs that pure strict: true misses.
Pro Tips
- Pick
nullorundefinedper field — not both. The third state is rarely meaningful. - Turn on
exactOptionalPropertyTypesin new projects. Migrating later is annoying. ?? 'default'not|| 'default'— the latter treats0and''as missing.- Check explicitly when absence carries meaning.
?.is a syntax tool, not a UX strategy. - At the boundary, validate. The schema decides what
null/undefinedmeans 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 👊



