I recently encountered the following situation with TypeScript, which will frequently occur in practice:
interface Options {
foo?: number;
bar?: string;
}
const defaultOptions = {
foo: 123,
bar: 'abc',
} as const satisfies Options;
function f(options: Options) {
const fullOptions = { // Type is inferred as {foo: number; bar: string} (regardless of satisfies)
...defaultOptions,
...options,
} as const satisfies Required<Options>; // This is not guaranteed and should be marked as error!
// The actual type is still just Options because the props of defaultOptions may be overwritten.
console.log(fullOptions);
return fullOptions.bar.toUpperCase();
}
f({foo: 456}); // Works
f({foo: 456, bar: undefined}) // Runtime error!
As you can see, TypeScript does not complain in the satisfies Required<Options>
part although it should. Is this intentional behavior or a bug?
TypeScript is behaving as intended; see microsoft/TypeScript#57408 for an authoritative answer.
Generally speaking, for optional properties, TypeScript doesn't make a distinction between properties which are missing and those which are present with an undefined
value. For a lot of purposes this is fine. For both const opts = {foo: 456}
and const opts = {foo: 456, bar: undefined}
, opts.bar
is undefined
. But there are some situations in which there is an observable difference, and the behavior of object spread is one such situation.
For a long time this was just the way things were. But enough people were unsatisfied that the feature request at microsoft/TypeScript#13195 received a lot of community support. Ultimately, TypeScript introduced the --exactOptionalPropertyTypes
compiler option as implemented in microsoft/TypeScript#43947. With that compiler option enabled, you will still see undefined
if you read from an optional property, but there will be an error if you try to write undefined
to an optional property (unless undefined
is explicitly included in its value type). If we enable that, then the following code produces a compiler warning:
f({foo: 456, bar: undefined}) // error!
// Argument of type '{ foo: number; bar: undefined; }' is not assignable to
// parameter of type 'Options' with 'exactOptionalPropertyTypes: true'.
// Type 'undefined' is not assignable to type 'string'.
And you can't accidentally overwrite an existing property with undefined
.
So if you really care about this, you might want to enable --exactOptionalPropertyTypes
. Note that this compiler option is not included in the --strict
suite of compiler options. Originally this was going to be --strictOptionalProperties
and included. But if you read the discussion at microsoft/TypeScript#44421 you will see that it was considered too much of a breaking change. The problem is that there's a large amount of real world code, including TypeScript's own libraries, that use optional properties. But since {x?: string}
and {x?: string | undefined}
were historically equivalent, what should happen to existing declarations like {x?: string}
? Did the original author intend to allow or disallow undefined
? There's no way to tell programmatically. That means someone would have to go through each and every optional property and make that decision. Otherwise, the next release of TypeScript would just enforce "disallow undefined
" everywhere, and lots of things break.
So, for better or worse, they moved it out of --strict
, renamed it to --exactOptionalPropertyTypes
at microsoft/TypeScript#44626, and told people to use it at their own risk. Maybe someday the ecosystem will be ready to include the flag in --strict
, but until and unless that happens, you need to decide whether the benefits outweigh the risks in your own code.