When dealing with object spread types, certain rules are applied as outlined in this guide:
Call and construct signatures are removed, only non-method properties are retained, and in cases where the same property name exists, the type of the rightmost property takes precedence.
With object literals containing generic spread expressions, intersection types are now generated, akin to the behavior of the Object.assign function and JSX literals. For instance:
Everything seems to work smoothly until an optional property is present in the rightmost argument. The ambiguity arises when dealing with a property like {value?: number; }
, which could signify either a missing property or a property set to undefined
. TypeScript struggles to differentiate between these two scenarios using the optional modifier notation ?
. Let's consider an example:
const t1: { a: number } = { a: 3 }
const u1: { a?: string } = { a: undefined }
const spread1 = { ...u1 } // { a?: string | undefined; }
const spread2 = { ...t1, ...u1 } // { a: string | number; }
const spread3 = { ...u1, ...t1 } // { a: number; }
spread1
makes sense - the key a
can be defined or left undefined. In this case, we must use the undefined
type to represent the absence of a property value.
spread2
The type of a
is dictated by the rightmost argument. If a
is present in u1
, it would be of type string
; otherwise, the spread operation retrieves the a
property from the first argument t1
, which has a type of number
. Thus, string | number
is a reasonable outcome in this context. Note that there is no mention of undefined
here because TypeScript assumes that the property does not exist at all or that it is a string
. To observe a different result, we could assign an explicit property value type of undefined
to a
:
const u2 = { a: undefined }
const spread4 = { ...t1, ...u2 } // { a: undefined; }
spread3
In this scenario, the value of a
from t1
replaces the value of a
from u1
, resulting in a return type of number
.
I wouldn't anticipate an immediate resolution to this issue based on discussions. Therefore, a potential workaround involves introducing a distinct Spread
type and function:
type Spread<L, R> = Pick<L, Exclude<keyof L, keyof R>> & R;
function spread<T, U>(a: T, b: U): Spread<T, U> {
return { ...a, ...b };
}
const t1_: { a: number; b: string }
const t2_: { a?: number; b: string }
const u1_: { a: number; c: boolean }
const u2_: { a?: number; c: boolean }
const t1u2 = spread(t1_, u2_); // { b: string; a?: number | undefined; c: boolean; }
const t2u1 = spread(t2_, u1_); // { b: string; a: number; c: boolean; }
Hoping this provides some clarity! Check out this interactive demo for the above code.