3

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:

enter image description here

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.

14
  • 4
    It is working correctly. The problem is exactly what it says: those two types aren't compatible. lastName can be (and indeed is) null in totoInt, but can't be in totoInt2. Commented Sep 21, 2022 at 21:10
  • One interface has the shape { lastName: lastName: string | null } while the other has { lastName: string }. TypeScript cannot guarantee that you're not assigning null to a property that expects a string, and hence the warning. Commented Sep 21, 2022 at 21:12
  • This is unfortunately a missing feature of TypeScript, see microsoft/TypeScript#42384. Type guarding properties only narrows the parent if the parent is of a discriminated union type, which TotoInt isn't (fixed to adhere to standard TS naming conventions). You can work around it by copying the property again like this or if you're doing this a lot, building a type guard function like this. Commented Sep 21, 2022 at 21:20
  • May I edit the code to make toto's lastName property be Math.random() < 0.5 ? null : "abc"? People in the comments are getting hung up on the fact that we know the value is null, but that doesn't seem to be the issue you're asking about. ... Please let me know if my prior comment fully addresses your question. If so, I could write up an answer. If not, what am I missing? (Please mention @jcalz if you reply so I'm notified). Commented Sep 21, 2022 at 21:22
  • Thank you guys for the prompt answers. I edited the question! Hope it's clearer! I will look at your answers tomorrow! Commented Sep 21, 2022 at 21:28

1 Answer 1

2

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.

Playground link to code

Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.