Reputation: 5912
type WithAvatar = {
avatar: string;
};
type CommonContent = {
name: string;
};
type CommonContentWithMaybeAvatar = CommonContent & {
avatar?: string;
};
let withoutAvatar: CommonContentWithMaybeAvatar = {name: "Kevin"}
let withAvatar: CommonContentWithMaybeAvatar = {name: "Kevin", avatar: "foo"}
function needsAvatar(item: WithAvatar) {
console.log(item.avatar)
}
if (withoutAvatar.avatar !== undefined) {
needsAvatar(withoutAvatar)
}
if (withAvatar.avatar !== undefined) {
needsAvatar(withAvatar)
}
Even though the code works completely as expected (it only logs one message), I am getting two errors from the TypeScript compiler: Argument of type 'CommonContentWithMaybeAvatar' is not assignable to parameter of type 'WithAvatar'
.
How can I conditionally cast an CommonContentWithMaybeAvatar
item to be a WithAvatar
item, IF the item's avatar isn't undefined? I know that in this case I can just force it like so:
if (withoutAvatar.avatar !== undefined) {
needsAvatar(withoutAvatar as WithAvatar)
}
if (withAvatar.avatar !== undefined) {
needsAvatar(withAvatar as WithAvatar)
}
But that doesn't feel like a good idea. Like, if I add more fields to WithAvatar
later on that might be optional in CommonContentWithMaybeAvatar
, the compiler wouldn't do anything to help me make sure ALL the required properties are there:
type WithAvatar = {
avatar: string;
anotherField: string;
};
type CommonContent = {
name: string;
};
type CommonContentWithMaybeAvatar = CommonContent & {
avatar?: string;
anotherField?: string;
};
let withPartialAvatar: CommonContentWithMaybeAvatar = {name: "Kevin", avatar: "foo"}
function needsAvatar(item: WithAvatar) {
console.log(item.anotherField)
}
if (withPartialAvatar.avatar !== undefined) {
needsAvatar(withPartialAvatar as WithAvatar)
}
This logs undefined
to the console, because we're not checking that all WithAvatar
fields are accounted for. I hope this makes sense :)
Upvotes: 1
Views: 880
Reputation: 328302
If you have an object obj
of a type like CommonContentWithMaybeAvatar
which is not a discriminated union, then unfortunately when you narrow the type of one of its properties via a type guard like obj.avatar !== undefined
, it only narrows the apparent type of that obj.avatar
property. It does not narrow the apparent type of the obj
object itself:
if (obj.avatar !== undefined) {
needsAvatar(obj); // error!
// Type 'string | undefined' is not assignable to type 'string'
}
There is an open feature request at microsoft/TypeScript#42384 to support such narrowing. Traditionally it hasn't been done because it would be "too expensive" to synthesize new types for all objects up a chain (e.g., if you write if (a.b.c.d.e.f !== undefined)
then you have a potentially large amount of work to do in transforming the original type of a
to its new apparent type). For now it's not part of the language.
So until and unless that is implemented, you have to work around it. One workaround is to write your own user-defined type guard function which tells the compiler how to do the narrowing you're doing. It could look like this:
function hasDefinedProp<T, K extends keyof T>(
obj: T, k: K
): obj is T & Required<Pick<T, K>> {
return obj[k] !== undefined;
}
Now your check would look like
if (hasDefinedProp(obj, "avatar")) {
needsAvatar(obj) // okay
}
Another workaround is to build a new object with the same properties as the old one, where obj.avatar
is explicitly copied so the compiler is walked through the logic:
if (obj.avatar !== undefined) {
needsAvatar({ ...obj, avatar: obj.avatar }); // okay
}
Personally I prefer the user-defined type guard since it is the closest to your original logic, but you might choose this spread-and-copy approach if you want the compiler to verify type safety completely on its own without taking some of that responsibility away from it (writing a type guard function is a form of this burden-shifting, since you could implement it completely wrong, like return obj[k] === undefined
and it would not error).
Upvotes: 1