Reputation: 307
I don't understand why this code:
interface TotoInt {
name: string;
lastName: string | null;
}
interface TotoInt2 {
name: string;
lastName: string;
}
const toto: TotoInt = {
name: 'toto',
lastName: Math.random() < 0.5 ? null : "abc",
};
if (toto.lastName) {
const toto2: TotoInt2 = {
...toto,
};
}
produces the following output:
I would have expected TypeScript to understand that by checking if (toto.lastName)
, toto.lastName
would be guaranteed to be non-null, thus allowing the usage of TotoInt2
.
If I do it this way instead (with the non-null assertion exclamation mark operator), TypeScript doesn't complain:
// if (toto.lastName) {
// const toto2: TotoInt2 = {
// ...toto,
// };
// }
const toto2: TotoInt2 = {
name: toto.name,
lastName: toto.lastName!,
};
Is this an issue with the way TypeScript (the version I use is 4.8.3) handles the spread operator? Is there no way around the full reconstruction of an object literal with the !
non nullable operator to make the code accept the usage of TotoInt2
?
The object is quite simple for demo purposes, but I'm working with a big object, that ideally I could pass into a function that would check for null values and that I thus wouldn't have to reconstruct entirely with a new object literal and !
non nullable operators.
Upvotes: 3
Views: 2072
Reputation: 327624
Narrowing only happens in particular circumstances.
If you perform a type guard on the property prop
of an object obj
, such as (typeof obj.prop === "string")
, you might reasonably expect that if obj.prop
is narrowed, then obj
will also be narrowed. That is, if the type checker now knows that obj.prop
is a string
as opposed to string | null
, then it should also know that obj
is an object type with a prop
property of type string
as opposed to string | null
. But generally speaking, this does not happen: if you perform a type guard on obj.prop
, generally only obj.prop
will be narrowed. The type of obj
itself will stay stubbornly wide.
( An exception to this is when obj
is of a discriminated union type and prop
is a discriminant property. But TotoInt
is not a union type at all, let alone a discriminated one, so checking a property of a TotoInt
object will only possibly narrow that property and not the parent object. )
There is a suggestion at microsoft/TypeScript#42384 to propagate narrowings of properties up to their parent objects. But for now, this is not part of the language.
Therefore you need to work around it. The easiest workaround is to copy the narrowed property explicitly, since that property is properly narrowed:
if (toto.lastName) {
const toto2: TotoInt2 = {
...toto,
lastName: toto.lastName // <-- copy over the checked prop again
}; // okay
}
If you find yourself running into this issue often enough, you could write a helper user-defined type guard function that you call instead of doing the type check. It's sort of a do-it-yourself implementation of microsoft/TypeScript#42384, and is accordingly clunky:
function hasPropType<T, K extends keyof T, V extends T[K]>(
obj: T, prop: K, guard: (v: T[K]) => v is V): obj is T & { [P in K]: V } {
return guard(obj[prop]);
}
The idea is that you check the property of key type K
of an object of type T
with a type guard function of type (v: T[K]) => v is V
, where V
is some narrower type than the known property type T[K]
. And this will serve to narrow obj
if the guard returns true.
For hasPropType
, you'd like to check if toto.lastName
is non-null, so you can write the following type guard function:
function isNonNullish<T>(x: T): x is NonNullable<T> {
return x !== undefined && x !== null;
}
(If you are using TS5.5 or greater you can leave off the x is NonNullable<T>
type annotation because that can now be inferred from the body of the function. For TS5.4 or below you need to write this manually.) Now the check looks like:
if (hasPropType(toto, "lastName", isNonNullish)) {
//const toto: TotoInt & { lastName: string; }
const toto2: TotoInt2 = { ...toto }; // okay
}
You can see that toto
gets narrowed from TotoInt
to TotoInt & {lastName: string}
, which is assignable to TotoInt2
.
Yes, that's clunky, but you might want this version if you have a reason why copying the property multiple times has bad side effects.
Upvotes: 2