Compiler options6 min read

TypeScript Optional Properties Are Not the Same as undefined

Use exactOptionalPropertyTypes to distinguish a missing property from a present undefined value in object APIs and patches.

  • optional properties
  • compiler options
  • API design

value?: string says an object may omit value. value: string | undefined says the property must exist, although its value may be undefined. With exactOptionalPropertyTypes enabled, TypeScript also enforces that distinction when code writes an optional property. Reading either form can still produce undefined.

The difference matters whenever presence carries meaning, including configuration overrides, partial updates, serialization, and checks with in or Object.hasOwn.

Missing and present are different runtime states

JavaScript can distinguish an absent property from one whose value is undefined:

const missing = {};
const present = { theme: undefined };

console.log('theme' in missing); // false
console.log('theme' in present); // true

Object.keys, object spread, property descriptors, and many merge routines also observe presence. A falsy check such as if (settings.theme) does not distinguish the states.

Model the intended state directly:

type Optional = { theme?: 'dark' | 'light' };
type RequiredMaybeUndefined = {
  theme: 'dark' | 'light' | undefined;
};

const a: Optional = {};
const b: RequiredMaybeUndefined = { theme: undefined };

The empty object is not assignable to RequiredMaybeUndefined because that contract requires the key.

Exact optional checking changes writes

Without exactOptionalPropertyTypes, TypeScript historically permits undefined to be assigned to an optional property even when its written type does not mention undefined. With the option enabled, this assignment fails:

interface Preferences {
  theme?: 'dark' | 'light';
}

const preferences: Preferences = {};
preferences.theme = 'dark';
preferences.theme = undefined; // error with exactOptionalPropertyTypes

If the present-but-undefined state is part of the contract, include it:

interface Preferences {
  theme?: 'dark' | 'light' | undefined;
}

The official exactOptionalPropertyTypes reference documents the same presence rule. TypeScript 4.4 introduced the option. It is not included by strict, so projects must enable it separately.

The option requires strictNullChecks. A typical project setting is:

{
  "compilerOptions": {
    "strict": true,
    "exactOptionalPropertyTypes": true
  }
}

Reads still include undefined

Even under exact optional checking, ordinary property access cannot prove that the property exists:

declare const preferences: Preferences;

const theme = preferences.theme;
// 'dark' | 'light' | undefined

The object might omit the key, so a read needs to account for undefined. The compiler option tightens which object shapes may be created or assigned. It does not make an optional read produce only the declared non-undefined members.

An in check establishes presence, which becomes more useful under the exact option:

function resolve(preferences: Preferences): string {
  if ('theme' in preferences) {
    return preferences.theme;
  }

  return 'system';
}

This works because the interface does not explicitly allow undefined. If it did, presence alone would not prove a string value, and another value check would be necessary.

in also sees inherited properties. Use Object.hasOwn(object, key) when an API specifically cares about own-property presence. The choice is a runtime contract, not just a narrowing trick.

Optional parameters follow a different rule

exactOptionalPropertyTypes applies to properties in object types. It does not prevent callers from passing undefined to an optional parameter:

function log(message?: string): void {
  console.log(message ?? '(none)');
}

log();
log(undefined);

Both calls are valid. JavaScript functions also cannot distinguish an omitted first argument from an explicit undefined by reading the parameter alone. They can inspect arguments.length when that distinction is truly part of the API.

Default parameters behave similarly:

function connect(timeout = 1_000) {
  return timeout;
}

connect();          // 1000
connect(undefined); // 1000

Do not use an optional parameter when call-site presence needs to be represented as data. An options object can preserve it.

Partial updates are where the flag pays off

Patch APIs often use Partial<Type>:

type Account = {
  name: string;
  suspended: boolean;
};

function applyPatch(account: Account, patch: Partial<Account>): Account {
  return { ...account, ...patch };
}

With exact optional checking, { name: undefined } is rejected as a Partial<Account>. That matches the spread implementation, which would otherwise overwrite a required string with undefined at runtime.

Some patch protocols intentionally use undefined to clear a field. Write that into the patch type instead of relying on the looser optional rule:

type AccountPatch = {
  name?: string | undefined;
  suspended?: boolean;
};

For JSON APIs, null is often a clearer explicit clearing value because JSON has no undefined value. The wire format and server contract should decide that choice.

Enabling the option can expose declaration assumptions

Turning on exactOptionalPropertyTypes may reveal object literals, assignments, and third-party declarations that used ? as shorthand for “or undefined.” Fix code according to runtime intent. Delete a property when absence is intended, or add | undefined when a present undefined value is valid.

Do not silence all errors by mechanically adding | undefined to every optional property. That restores the old ambiguity and loses the reason for enabling the option. The useful outcome is an object model where omission and explicit clearing mean what the implementation already does.