An optional TypeScript property looks simple: write timeoutMs?: number and move on. The trouble starts when an object is assembled with spreads or when an API interprets "missing key" as "use the default." In that case, {} and { timeoutMs: undefined } are not the same object.
With exactOptionalPropertyTypes: true, the compiler makes that difference explicit. timeoutMs?: number lets you omit the key or send a number. If the contract also accepts a present key with undefined, write timeoutMs?: number | undefined. The option helps when key presence carries meaning, but it does not replace runtime validation.
This article is a spoke from validating tool calls in TypeScript. That owner covers untrusted data arriving at a tool. This page answers the earlier question: what object can the code construct, and what should each state mean?

Short answer
- Use
prop?: Twhen the key may be omitted and, when present, must containT.- Use
prop?: T | undefinedwhen a present key withundefinedis a valid state.- Turn on
exactOptionalPropertyTypeswhen the code distinguishes absence,undefined, and possiblynull.- Review objects built with spread: passing
undefinedto mean "not provided" can leave a key present.- Keep validating JSON, HTTP responses, and tool arguments at runtime.
What does exactOptionalPropertyTypes change?
Without the option, TypeScript treats an optional property as if its assignment type also accepts undefined in many situations. This usually compiles:
interface UserPreferences {
colorTheme?: "dark" | "light";
}
const preferences: UserPreferences = {
colorTheme: undefined,
};
With "exactOptionalPropertyTypes": true, the declaration is read more literally. colorTheme?: "dark" | "light" means the key may be absent or may contain one of the two strings. Assigning undefined becomes an error unless undefined appears explicitly in the type.
The TypeScript TSConfig reference for exactOptionalPropertyTypes uses the same contrast: absence represents one state, while a key with undefined is still a key. The TypeScript 4.4 release notes also explain that the option is not part of the strict family and requires strictNullChecks.
This mostly changes what code may write. When you read preferences.colorTheme, the value can still be "dark" | "light" | undefined because the key may be missing. The option does not make an optional read non-optional.
Why is an absent key different from undefined?
JavaScript can represent both states:
const omitted = {};
const present = { timeoutMs: undefined };
console.log("timeoutMs" in omitted); // false
console.log("timeoutMs" in present); // true
console.log(Object.keys(omitted)); // []
console.log(Object.keys(present)); // ["timeoutMs"]
The in operator checks whether a property exists on an object or its prototype chain. MDN also points out that a property can exist while its value is undefined, so "x" in object is not the same as object.x !== undefined.
The difference appears in code that merges options. Imagine an executor that keeps a default when timeoutMs was not supplied:
type ExecutorOptions = {
timeoutMs?: number;
};
const defaults = { timeoutMs: 10_000 };
const incoming: ExecutorOptions = { timeoutMs: undefined };
const options = { ...defaults, ...incoming };
console.log(options.timeoutMs); // undefined
The spread does not know that undefined was meant to mean "do not change the default." It copies the present property. Object.assign() follows the same idea: it copies enumerable own properties, and later sources overwrite earlier keys (MDN, Object.assign()).
The TypeScript FAQ uses a similar example to show why Partial<T> values containing undefined can be suspicious when combined with spread (TypeScript FAQ). That does not make every spread wrong. The type still needs to say whether key presence matters.
How should you model configs and options?
Start by writing the contract before turning on the flag. There are at least three different intentions.
The key may be omitted, but it may not be undefined
Use a normal optional property when the caller may leave the value out:
type ToolOptions = {
timeoutMs?: number;
signal?: AbortSignal;
};
const defaults: ToolOptions = {};
const withTimeout: ToolOptions = { timeoutMs: 5_000 };
With exactOptionalPropertyTypes, const invalid: ToolOptions = { timeoutMs: undefined } fails. The error is useful when the executor distinguishes an option that was not supplied from one that was explicitly configured.
A present key with undefined is valid
Some APIs use key presence to standardize generic options, compose adapters, or preserve a public signature. In that case, declare the state explicitly:
type PublicOptions = {
timeoutMs?: number | undefined;
};
const allowed: PublicOptions = { timeoutMs: undefined };
The ? still allows you to omit the key. The | undefined adds the possibility that the key exists with that value. Do not add the union just to silence the compiler. It documents a contract choice.
null represents a different operation
In an update payload, absence may mean "do not change" while null means "clear":
type ProfilePatch = {
displayName?: string;
bio?: string | null;
};
const rename: ProfilePatch = { displayName: "Ada" };
const clearBio: ProfilePatch = { bio: null };
That meaning belongs to your API. exactOptionalPropertyTypes helps keep accidental undefined out, but it does not choose whether null should clear a field. Document the rule and test the accepted states at the boundary that receives the payload.
For a tool registry written in code, use satisfies to check the config without losing inference. These checks complement each other: satisfies checks the static shape of the object, while exactOptionalPropertyTypes prevents a present undefined key from being treated as absent.
How can you enable the option without guessing through the migration?
Enable it with strictNullChecks, as in this minimal tsconfig.json:
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"exactOptionalPropertyTypes": true,
"noEmit": true
}
}
The verification command can stay simple:
npx tsc --noEmit
To protect a specific behavior, use a type test with @ts-expect-error:
type RequestOptions = {
cache?: "no-store" | "force-cache";
};
const request: RequestOptions = {};
request.cache = "no-store";
// @ts-expect-error: the contract requires absence or a known mode
request.cache = undefined;
A safe migration separates four questions:
- Did the error appear because the code meant to omit the key? Build the object conditionally.
- Did it appear because
undefinedis a valid public state? Add| undefinedto the declaration that expresses that intent. - Did it come from a dependency or generated types? Check the version and the type that caused the incompatibility. Do not hide it with a global assertion.
- Did it come from an external value? Do not fix it by changing an interface. Validate the data before converting it to an internal type.
For the first question, avoid inserting the key when the condition is false:
const options: RequestOptions = {
...(useCache ? { cache: "force-cache" } : {}),
};
The guide to validating external JSON before using it in TypeScript covers the separate boundary: a static type does not confirm that received JSON has the declared shape.
What does the flag not prove?
exactOptionalPropertyTypes acts during TypeScript assignment checking. It does not inspect an HTTP response, validate a queue message, or stop any and an incorrect cast from crossing the program. It also does not guarantee that an implementation follows the documented meaning of null.
In a tool call, for example, you still need to validate received arguments before execution. Validating tool calls in TypeScript covers that runtime contract. The compiler can stop your own code from constructing one invalid object; it cannot check an object created outside the process by itself.
There is also a compatibility cost. The flag affects every optional property in the project, not only one interface. Public types, generated code, and dependencies may assume that prop?: T accepts undefined. The TypeScript FAQ recommends understanding that impact in the context of the project. There is no per-type setting that enables the semantics for only one isolated interface.
A decision checklist
Before enabling the option, answer these questions:
- Does an absent key mean "use the default," "inherit," or "leave unchanged"?
- Does a present key with
undefinedhave a different behavior? - Is
nullthe clearing value, or should it be rejected? - Does any spread or
Object.assign()copy options that carryundefined? - Do public declarations and generated types accept the semantics you want?
- Does the code still validate external input at runtime?
- Is there a compile-time test that fails if the distinction gets erased again?
If the answer to the first question is yes and undefined has no separate meaning, exactOptionalPropertyTypes is a direct way to make the type reflect the contract. If not, leaving the flag off can be coherent, as long as that flexibility is intentional and covered by tests.
Method and limits
Samuel Fajreldines is responsible for this article. The research compared the current TSConfig reference, TypeScript 4.4 release notes, the project FAQ, and MDN documentation for property presence and Object.assign(). The minimal fixture was compiled with a temporary TypeScript installation, and the presence example was run with Node.js. The examples demonstrate the described contract, but they are not a migration benchmark or an ecosystem compatibility measurement.
Sources
- TypeScript,
exactOptionalPropertyTypes, retrieved November 9, 2026. - TypeScript, 4.4 release notes, retrieved November 9, 2026.
- TypeScript, FAQ: Require Properties to Either Be Missing or Not
undefined, retrieved November 9, 2026. - MDN,
inoperator, retrieved November 9, 2026. - MDN,
Object.assign(), retrieved November 9, 2026.