skip to content

What does enabling TypeScript's `exactOptionalPropertyTypes` compiler option change about optional properties, and how do you declare one that must still accept an explicit `undefined`?

level: seniorimportance: should knowfreq 36%

answer

  1. absence and present-undefined stop being the same
  2. only the write side tightens
  3. spell undefined into the type to allow it
  4. not switched on by strict
  5. spread merges are where it pays off

basics

~20 s

With exactOptionalPropertyTypes on, a property marked ? means only "the key may be missing" — writing the key with the value undefined becomes an error. To allow that, declare the type explicitly as name?: string | undefined.

solid answer

~40 s

By default `?` blurs two different runtime shapes: a missing key and a key present holding `undefined` are both accepted. Turning on `exactOptionalPropertyTypes` splits them. The optional property then admits only absence, so `{ name: undefined }` is rejected with a message telling you to add `undefined` to the target's type; the fix is to declare `name?: string | undefined` when explicit `undefined` is genuinely meaningful. Reading is unaffected — `obj.name` is still `string | undefined`, because the key may be missing. The flag is not part of `strict`, so you opt in separately, and it only makes sense alongside `strictNullChecks`. It pays off in code that merges objects by spreading or builds PATCH-style payloads, where a present `undefined` key overwrites a value while an absent key does not.

code

typescript · 12 lines
typescript
// tsconfig: { "strict": true, "exactOptionalPropertyTypes": true }
interface User { name?: string }
interface Patch { name?: string | undefined }

const a: User = {};                   // ok
const b: User = { name: 'ada' };      // ok
// const c: User = { name: undefined }; // error under the flag

const p: Patch = { name: undefined }; // ok — undefined spelled out

declare const u: User;
const read: string | undefined = u.name; // reads are unchanged

go deeper

for a junior

Know that the option exists and that with it on, writing { name: undefined } for a name?: string property is an error rather than an accepted equivalent of leaving the key out.

for a middle

Explain the exact semantic change on the write side, the name?: string | undefined escape hatch, and that reads still yield T | undefined because the key may simply be absent.

for a senior

Justify the flag with real failure modes — spread merges where a present undefined overwrites a default, and patch payloads where "not supplied" and "cleared" must not collapse into one shape.

for a principal

Own the rollout decision: the flag is not in strict, it produces a long tail of mechanical fixes, and third-party declarations may not honour it — so weigh enabling it per package against a codebase-wide sweep.

## The blur the flag removes JavaScript objects distinguish two states that TypeScript, by default, does not: ```ts const missing: { name?: string } = {}; const presentUndefined = { name: undefined }; 'name' in missing; // false 'name' in presentUndefined; // true ``` With default settings, both satisfy `{ name?: string }`. The `?` modifier is read as *may be absent **or** present-undefined*. `exactOptionalPropertyTypes` narrows it to *may be absent*, full stop. ```ts // exactOptionalPropertyTypes: true interface User { name?: string } const a: User = {}; // ok const b: User = { name: 'ada' }; // ok const c: User = { name: undefined }; // error ``` The diagnostic explicitly points at the flag and suggests adding `undefined` to the target type — which is the escape hatch: ```ts interface Patch { name?: string | undefined } const p: Patch = { name: undefined }; // ok again ``` So under the flag you get three distinguishable declarations rather than two: `name?: string` (absent or string), `name?: string | undefined` (absent, explicit undefined, or string), and `name: string | undefined` (always present, undefined or string). ## What does not change Reads are untouched. `user.name` is still `string | undefined`, because a missing key still produces `undefined` when read. Candidates often expect the flag to *narrow the read* to `string`; it does not, and it cannot — the whole point of the modifier is that the key may not be there. The flag is about what may be written, not about what comes back. Assignment of a whole object still follows normal assignability, and the flag also applies when writing to a property of an existing value: `user.name = undefined` is rejected for `name?: string` under the flag. ## It is not part of `strict` `exactOptionalPropertyTypes` must be enabled on its own; switching on `strict` does not turn it on. It also only makes sense with `strictNullChecks` on, since without null-checking `undefined` is not tracked in the first place. Keeping it out of `strict` was deliberate: it breaks a large amount of existing code, especially code that passes option bags through and lets `undefined` mean "no opinion". ## Why anyone pays that price The payoff is in code where presence itself carries meaning. **Spread-based merging.** A present key wins over an earlier one, regardless of its value: ```ts const defaults = { retries: 3 }; const overrides = { retries: undefined }; ({ ...defaults, ...overrides }).retries; // undefined, not 3 ``` Without the flag, a function typed to take `{ retries?: number }` accepts that `overrides` object happily and silently wipes the default. With the flag, the caller has to be explicit — either omit the key or declare that `undefined` is a legal value with a defined meaning. **Patch semantics.** In an update API, "field not supplied" and "field cleared" are different operations. If both are typed as `?`, the type system cannot police the difference; under the flag, `clear` has to be modelled deliberately, typically as `| null` or as a dedicated union member rather than `undefined`. **Key-presence logic.** Anything that branches on `in`, `Object.hasOwn`, or `Object.keys().length` is reasoning about presence, and the flag makes the types match that reasoning. ## The migration cost Enabling it on an existing codebase surfaces a large class of errors of the shape *Type '{ x: string | undefined }' is not assignable to type '{ x?: string }'*, typically where a value was built with a conditional expression: ```ts // errors under the flag const u: User = { name: maybe ? 'ada' : undefined }; // fixes const u2: User = maybe ? { name: 'ada' } : {}; const u3: User = { ...(maybe && { name: 'ada' }) }; ``` The fixes are mechanical but numerous, and third-party declarations you do not control may not have been written with the flag in mind — a common reason teams enable it on new packages rather than retrofitting a large existing one. ## How to answer State the semantic change in one sentence, give the `name?: string | undefined` escape hatch, note that reads are unchanged and the flag is opt-in outside `strict`, and finish with the concrete reason it exists: spread and patch code where a present `undefined` key destroys data that an absent key would have left alone.

  • Does the flag change the type you get when reading an optional property?
    No. `user.name` remains `string | undefined`, because the key may still be missing and a missing key reads as `undefined`. The flag constrains what may be assigned into the property, not what comes out of it. Anyone expecting reads to narrow to `string` has misread what optionality means.
  • Is exactOptionalPropertyTypes enabled by the strict option?
    No — it is a separate opt-in, and it belongs alongside `strictNullChecks` to be meaningful. It was kept out of `strict` because it breaks a great deal of existing code that passes `undefined` around to mean "no opinion", including third-party declarations you cannot edit.
  • A conditional expression now fails to type-check under the flag. What is the idiomatic fix?
    Stop producing the key at all in the absent branch. Instead of `{ name: cond ? 'ada' : undefined }`, write `cond ? { name: 'ada' } : {}`, or spread conditionally with `{ ...(cond && { name: 'ada' }) }`. If explicit `undefined` genuinely carries meaning, widen the declaration to `name?: string | undefined`.

saying these in an interview costs you the question

  • Thinks the flag makes optional reads narrow to T
  • Believes strict turns the flag on
  • Says it changes the emitted JavaScript
  • Claims absent and present-undefined behave the same at runtime
  • Confuses it with strictNullChecks

context