skip to content

In TypeScript, what does the exactOptionalPropertyTypes compiler option change about a property declared retries?: number, and how do you keep allowing an explicit undefined?

level: middleimportance: should knowfreq 30%

answer

  1. absent versus present-and-undefined
  2. the question mark stops implying undefined
  3. writes are constrained, reads are not
  4. widen the declaration to allow undefined

basics

~20 s

exactOptionalPropertyTypes separates absent from present-and-undefined. With it on, retries?: number means the property may be missing but must hold a real number when present, so assigning { retries: undefined } is an error; declare retries?: number | undefined to allow both.

solid answer

~50 s

By default `retries?: number` quietly means two things at once: the key may be absent, or it may be present holding `undefined`. `exactOptionalPropertyTypes` splits those cases. With the flag on, the property may still be omitted, but if it is written it must hold a real `number`, so `const o: Options = { retries: undefined }` fails with the error about `exactOptionalPropertyTypes: true` — and so does spreading in a value whose type is `number | undefined`. Reads are unchanged: `o.retries` is still `number | undefined`, because the property may genuinely be missing. If your API really does accept an explicit `undefined` — a common shape for options merging — declare it as `retries?: number | undefined` and both forms are allowed again. The flag also tightens `Partial<T>`, which is where most rollouts feel the pain, since update-and-merge code often passes `undefined` to mean "leave it alone".

code

typescript · 15 lines
typescript
interface Options {
  retries?: number;             // if present, a real number
  label?: string | undefined;   // may be present and explicitly undefined
}

const a: Options = {};                   // ok
const b: Options = { retries: 3 };       // ok
const c: Options = { label: undefined }; // ok - undefined is in the declared type

function apply(o: Options) {
  const r: number | undefined = o.retries; // reads still admit undefined
  return r ?? 3;
}

console.log(a, b, c, apply(b));

go deeper

for a junior

Know that with this option on, an optional property may be left out but cannot be written as an explicit undefined, and that adding | undefined to the declaration restores the old behaviour.

for a middle

Explain that the flag constrains assignment rather than reads, show the conditional-spread alternative to assigning undefined, and note that Partial<T> tightens along with it.

for a senior

Demonstrate why absence versus explicit undefined is behaviourally real — key presence checks, serialisation, merge semantics — and lead the per-property audit the rollout demands, since no autofix can guess the intent.

for a principal

Set the convention for how your APIs express "no value": omitted key, explicit undefined, or an explicit sentinel such as null for clearing. This flag makes that choice visible in every type, so decide it once at the platform level rather than per team.

## Two different things the default conflates In JavaScript, `{}` and `{ retries: undefined }` are not the same object. `'retries' in obj` is false for the first and true for the second, `Object.keys` lists the key only in the second, and `JSON.stringify` drops an `undefined`-valued key on the way out. TypeScript's default modelling of `retries?: number` ignores that distinction and treats the property as `number | undefined` — present-with-undefined and absent become the same type-level state. `exactOptionalPropertyTypes` makes the type reflect the distinction: ```ts interface Options { retries?: number } const a: Options = {}; // ok - absent const b: Options = { retries: 3 }; // ok - present with a number const c: Options = { retries: undefined }; // error under the flag ``` The error is explicit about the cause and even names the fix: it suggests adding `undefined` to the target property's type. ## The escape hatch is a declaration, not an assertion When a property legitimately accepts an explicit `undefined`, say so in the type: ```ts interface Options { retries?: number; // may be absent; present means a real number label?: string | undefined; // may be absent, or present and undefined } ``` That is the whole design of the flag: `?` now means only "may be omitted", and if you also want `undefined` as a value you write it. This turns a previously invisible API decision into an explicit one, which is the point — a caller reading the type can now tell whether passing `undefined` explicitly is meaningful. ## Reads are unchanged A frequent misreading is that the flag makes `o.retries` a plain `number`. It does not. Because the property may be absent, reading it still yields `number | undefined`, and you still guard or default it at the use site. The flag constrains what you may *write*, not what you may assume when reading. ## Where the errors actually appear The error surface is narrower than `noUncheckedIndexedAccess`, but it concentrates in a recognisable place: code that builds or merges option objects. ```ts declare const maybe: number | undefined; const o: Options = { retries: maybe }; // error - number | undefined is not assignable to number ``` This is the pattern where a caller forwards a possibly-missing value straight into a config object. Under the flag you must either widen the declaration to `retries?: number | undefined`, or build the object conditionally so the key is simply not written when there is no value — for example by spreading `...(maybe !== undefined && { retries: maybe })`. `Partial<T>` inherits the same rule, since it produces optional properties. Update-style code that passes `{ x: undefined }` through a `Partial` to mean "no change" stops compiling, and the team has to decide what it actually meant: omit the key, or declare the field as accepting `undefined`. ## Why this is a correctness flag, not a style flag The distinction has teeth wherever absence and explicit-undefined lead to different behaviour. A PATCH-style API where an omitted field means "leave unchanged" and a null-ish field means "clear it" is the canonical case; so is any code that branches on `in` or iterates `Object.keys`, and any object destined for serialisation, since an explicitly-undefined key vanishes on the way out. Without the flag, a type simply cannot express "do not send me a key holding undefined", and the resulting bug lives at the boundary where the object is consumed, far from where it was built. ## The cost side One consequence surprises people: the flag makes some code *harder* to write on purpose. Conditional-spread construction is wordier than assigning `undefined`, and every options interface has to be audited once to decide which properties genuinely accept `undefined`. On a large codebase that is a real, if bounded, piece of work — and unlike a lint rule there is no autofix, because only the author knows the intended semantics of each property.

  • After enabling the flag, what is the type of `o.retries` when reading from `interface Options { retries?: number }`?
    Still `number | undefined`. The property can be absent, and reading a missing property yields `undefined` at runtime, so the read type has to include it. The flag changes only what may be *assigned*: you can no longer write the key with an explicit `undefined` value unless the declaration says `retries?: number | undefined`.
  • How would you build an options object from a value that might be missing, without widening the property's declared type?
    Do not write the key at all when there is no value. A conditional spread does it in one expression — `{ ...defaults, ...(maybe !== undefined && { retries: maybe }) }` — and produces an object where the key is genuinely absent rather than present-and-undefined. Widening the declaration to `retries?: number | undefined` is the alternative, and it is the right one only if callers really are allowed to pass `undefined` deliberately.
  • Why does this distinction matter beyond the type-checker — where does it change behaviour?
    Anywhere absence is observable. `'key' in obj` and `Object.keys` report a present-but-undefined key, JSON serialisation drops it, and merge helpers that copy own keys will overwrite a good default with `undefined`. PATCH-style APIs lean on exactly this difference: omitted means leave unchanged, present means apply. The flag lets the type say which one you meant.

saying these in an interview costs you the question

  • Thinks the flag makes reading an optional property non-undefined
  • Says absent and present-with-undefined are the same object
  • Uses an assertion instead of widening the declaration
  • Assumes Partial<T> is unaffected by the flag
  • Believes the flag adds a runtime presence check

context