Ch. 2 · TypeScript

TypeScript Optional Properties and Presence

A missing key and a key set to undefined behave differently in spreads, JSON and the in operator; exactOptionalPropertyTypes makes types match.

~5 min readbeginnerupdated Oct 7, 2026

There are two ways for an object to have “no name”: it can lack the name key entirely, or it can have name: undefined. Most of the time JavaScript treats them the same, since reading either gives undefined. But spreads, JSON.stringify, Object.keys and the in operator all tell them apart, and that’s where bugs hide. TypeScript’s default settings blur the distinction too. This is a beginner-level topic with an advanced edge, and interviewers use it to see whether you know what your types actually promise.

Two kinds of nothing

Here’s a type for a profile update, the kind of object a PATCH endpoint receives:

type UpdateUser = {
  name?: string;
  bio?: string | null;
};
TypeScript

The ? makes each key optional: an update can contain any subset of fields. Now build one from form values, where the name input was left empty and the code turned that into undefined:

const fromForm: UpdateUser = { name: undefined, bio: "Hi" };
console.log(JSON.stringify(fromForm), Object.keys(fromForm), "name" in fromForm);
TypeScript

That compiles under strict, and running it prints:

{"bio":"Hi"} [ 'name', 'bio' ] true
Text

JSON.stringify drops the key, because JSON has no undefined. Object.keys and in keep it, because the property exists, it just holds undefined. So the same object looks like “name not sent” once it’s serialised and “name present” while it’s in memory. Which one your code sees depends on where it sits.

The spread that wipes your defaults

The most common real bug comes from merging an update into defaults or into the current record:

const defaults = { name: "Anonymous", bio: "No bio yet" };

function applyUpdate(update: UpdateUser) {
  return { ...defaults, ...update };
}

console.log(applyUpdate(fromForm));
// { name: undefined, bio: 'Hi' }
TypeScript

Object spread copies every own property, including ones whose value is undefined. So name: undefined in the update overwrote "Anonymous". If fromForm had simply not had a name key, the default would have survived.

Here’s the part worth saying in an interview: TypeScript doesn’t catch this. Hover over the result and name is typed string, so const n: number = applyUpdate({}).name fails with Type 'string' is not assignable to type 'number'. The checker assumes that an optional property, if present, holds a string, so spreading it over a string leaves a string. At runtime it’s undefined, and the next .toUpperCase() throws. The types and the values have quietly disagreed.

What exactOptionalPropertyTypes changes

The root cause is that, by default, name?: string really means name?: string | undefined. TypeScript 4.4 added a flag that separates the two meanings. With exactOptionalPropertyTypes on, ? means only “the key may be absent”. Assigning undefined explicitly is an error:

opt1.ts(12,7): error TS2375: Type '{ name: undefined; bio: string; }' is not assignable to type 'UpdateUser' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
  Types of property 'name' are incompatible.
    Type 'undefined' is not assignable to type 'string'.
Text

That’s the same line that compiled before. Now the spread’s string result type is honest, because the only way to get undefined into name has been closed off. If a property really should accept an explicit undefined, you say so: name?: string | undefined.

The flag is not part of strict, and turning it on in an existing codebase usually produces a batch of errors. Many come from code like { name: form.name || undefined }, which is exactly the pattern that causes the spread bug, so fixing them is worthwhile. Some come from third-party type definitions that write ? where they mean | undefined. It’s a good default for new projects and for any code that merges partial objects.

To build an update without explicit undefined, only add keys that have values:

function toUpdate(form: { name: string; bio: string }): UpdateUser {
  const update: UpdateUser = {};
  if (form.name.trim() !== "") update.name = form.name.trim();
  if (form.bio !== "") update.bio = form.bio;
  return update;
}
TypeScript

That compiles under both settings and never produces the ambiguous shape.

Absent, null and value: a three-state field

Updates often need three meanings per field: leave it alone, clear it, or set it. bio?: string | null encodes all three. Absent means unchanged, null means cleared, and a string means set:

const patchA: UpdateUser = {};
const patchB: UpdateUser = { bio: null };

function describe(u: UpdateUser): string {
  if (u.bio === undefined) return "bio unchanged";
  if (u.bio === null) return "bio cleared";
  return `bio set to ${u.bio}`;
}

console.log(describe(patchA), describe(patchB), describe({ bio: "x" }));
// bio unchanged bio cleared bio set to x
TypeScript

The check order matters. === undefined first, then === null, and what’s left is narrowed to string. A loose if (!u.bio) would lump all three together with the empty string, which is the bug you were trying to avoid. u.bio == null (double equals) matches both null and undefined, so it’s useful when you don’t care which, but not here.

This convention matches JSON well. null survives serialisation, and an absent key stays absent. Without exactOptionalPropertyTypes, though, nothing stops a caller writing { bio: undefined } and meaning “clear”, so the flag and the convention work best together.

Checking for presence

You have two ways to ask whether a key was provided, and they answer slightly different questions.

u.bio !== undefined asks whether there’s a usable value. It’s the right check for reading, and it narrows the type.

"bio" in u asks whether the key exists, regardless of value. It’s the right check when presence itself carries meaning and you can’t rule out explicit undefined. It also walks the prototype chain, so for objects built from untrusted input Object.hasOwn(u, "bio") is safer. With exactOptionalPropertyTypes on, the two checks agree for well-typed values, which is another reason to enable it.

Optional parameters (function f(x?: number)) are a related but separate feature. The flag doesn’t affect them: calling f(undefined) stays legal, because a missing argument and an undefined argument are the same thing at runtime.

Interview practice

A React settings page keeps the user in state and saves edits with setUser({ ...user, ...changes }), where changes is typed Partial<User> and built as { email: emailInput || undefined, phone: phoneInput || undefined }. Users report that saving after changing only their email makes the phone number disappear from the page. TypeScript reported nothing. Explain the bug and how you’d prevent it.

Answer

When the phone input is empty because the user didn’t touch it, phoneInput || undefined produces phone: undefined, and the key is present on changes. Object spread copies own properties even when their value is undefined, so it overwrites the existing phone with undefined. TypeScript stayed quiet because, by default, an optional property accepts an explicit undefined, and the spread’s result is still typed as if phone were a string. The type says string; the value is undefined.

To fix the code, build changes by adding only the fields that were actually edited, for example by tracking dirty fields or using the if (...) changes.phone = ... pattern, so untouched fields are absent rather than undefined. To stop it recurring, turn on exactOptionalPropertyTypes. Then { phone: phoneInput || undefined } is a compile error against Partial<User>, which flags this exact pattern everywhere it appears. If the product really needs “clear my phone number”, represent that with null and handle it explicitly rather than overloading undefined. A component test that edits only the email and asserts the phone is still shown would cover the regression.

Further reading

More in TypeScript

read ✓TypeScript · mid

TypeScript Enums vs Union Types

What TypeScript enums compile to, why string enums reject plain strings from JSON, and when an as const object beats both options.

~6 min readread →
esc