Question:
Why don't I receive a compile-time error when I forget to add nested fields to an object of type T
, when constructing said object using an object spread?
Example:
interface User {
userId: number;
profile: {
username: string
}
}
function updateUsername(user: User): User {
return {
...user,
profile: {
// Error (as expected)
}
}
}
function updateUsernameGeneric<T extends User>(user: T): T {
return {
...user,
profile: {
// No error (unexpected... why?)
}
}
}
My own speculation on the answer:
All I can imagine is that TypeScript allows subtypes to remove properties of their supers, making it possible that for some subtype T
of User
, the profile
property might not contain any properties. (If so, I was unaware TypeScript allowed you to do this...)
TypeScript Version 4.1.2
Playground
This has all to do with how the spread is resolved with generic types (see PR) compared to normal types. If you write the resulting object to a variable, you will immediately notice the difference: for non-generic types, the merged type is inferred as:
{
profile: {};
userId: number;
}
which leads to this type being unassignable to the annotated return type User
that has a required username
subproperty. This is exactly what the compiler error TS 2322 is telling you:
Property 'username' is missing in type '{}' but required in type '{ username: string; }'
Now, the situation with generics is a bit different: the type is actually inferred as an intersection of a subtype of User
and a { profile: {}; }
type:
T & {
userId: string;
profile: {};
}
The compiler is ok with that as the intersection is an "extension" of the annotated return type containing all the properties as per the definition of the intersection.
Whether this a good behavior is debatable as you can do the following, and the compiler will be none the wiser:
function updateUsernameGeneric<T extends User>(user: T): T {
const newUser = {
...user,
userId: "234",
profile: {
// No error (unexpected... why?)
}
}
return newUser;
}
updateUsernameGeneric({ profile: { username: "John" }, userId: 123 }).userId //a-ok, "number"
Since the return type is an intersection of the generic type parameter and the merged properties, you can de-annotate the return type and let TypeScript infer it. Incompatible property types will be correctly inferred as never
:
function updateUsernameGenericFixed<T extends User>(user: T) {
const newUser = {
...user,
userId: "234",
profile: {
// No error (unexpected... why?)
}
}
return newUser;
}
updateUsernameGenericFixed({ profile: { username: "John" }, userId: 123 }).userId //never
Playground
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With