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`?
answer
- absence and present-undefined stop being the same
- only the write side tightens
- spell undefined into the type to allow it
- not switched on by strict
- spread merges are where it pays off
basics
~20 sWith 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 sBy 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// 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 unchangedgo deeper
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.
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.
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.
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