You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
control flow analysis incorrectly narrows type to never when type-guarded #45664
The new control flow analysis incorrectly narrows a type that is type-guarded to fallback to never in the else clause when they should either remove the type-guarded type or stay the original type if not explicitly typed earlier. They should only fallback to never when all types are exhausted (if enum or was an initial set of types).
🔎 Search Terms
control flow analysis never type narrowing type guard
🕗 Version & Regression Information
This changed between versions 4.3.5 and 4.4
This is still a problem in 4.5.0 nightly as of 2021-08-31
interfaceBox{isSmallBox(): this is SmallBox;isLargeBox(): this is LargeBox;sayHello(): any;}interfaceSmallBoxextendsBox{sayHello(): string;}interfaceLargeBoxextendsBox{sayHello(): number;}constb: SmallBox|LargeBox={isSmallBox(){returnfalse;},isLargeBox(){returntrue;},sayHello(){return0.7734;}}constisLargeBox=b.isLargeBox();if(isLargeBox){b.sayHello();}else{b.sayHello();}
🙁 Actual behavior
The last b.sayHello() has b as type never, causing an error.
🙂 Expected behavior
The last b.sayHello() should have b as type SmallBox
This is the correct behaviour. Your variable b is not typed SmallBox | LargeBox, it is typed LargeBox. TypeScript correctly sees that the value isLargeBox is alwaystrue, and as a result you end up with never in the else-branch.
You see the behaviour you expect if you type b as SmallBox | LargeBox, e.g. using a type-assertion: const b = { ...} as SmallBox | LargeBox.
You have the same behaviour in TypeScript 4.3.5 when you replace isLargeBox with b.isLargeBox(). TypeScript 4.3.5 did not have the feature to narrow indirectly via local variables: #44730 - You can opt out from this feature by adding an explicit boolean type annotation to your variable: const isLargeBox: boolean = b.isLargeBox();
That b is narrowed to LargeBox despite your explicit type annotation SmallBox | LargeBox is intentional as well. Assigning a value to a variable narrows the variable to the assigned type. Otherwise code like this would not work:
const value: number | undefined = 123;
value.toString(); // If this were still typed `number | undefined` then we couldn't call the function.
// But TypeScript can see that a number is always assigned and the value can't be `undefined`.
TypeScript correctly sees that the value isLargeBox is alwaystrue
To clarify, it sees this not because of the return true in the object literal initializer; it sees this because of everything else Martin Johns (@MartinJohns) explained; that b is immediately narrowed to LargeBox and then it simply looks at this is LargeBox and says “yep, it sure is.”
I don't think TypeScript sees isLargeBox always returns true.
Instead, I think it sees sayHello() return number, making b a LargeBox, which means b has an impossible type in the else statement.
You can validate this by checking that b.sayHello() returns number before any narrowing. But b.isLargeBox() is still described as returning an open value: this is LargeBox. TypeScript doesn't look into the function's implementation to see if it always return true or not.
Looks like I have the wrong minimal testcase. This is what I intended to do, let me know if I should open a different issue:
interfaceBox{isSmallBox(): this is SmallBox;isLargeBox(): this is LargeBox;sayHello(): any;}interfaceSmallBoxextendsBox{sayTruth(): boolean;}interfaceLargeBoxextendsBox{}functionhello(a: SmallBox|LargeBox,b: SmallBox|LargeBox){constisLargeBox=a.isLargeBox()||b.isLargeBox();if(isLargeBox){a.sayHello();}else{a.sayHello();}}functionhello(a: Box,b: Box){constisLargeBox=a.isLargeBox()||b.isLargeBox();if(isLargeBox){a.sayHello();}else{a.sayHello();}}
In both functions, the second a.sayHello() has a as never.
As a workaround, i can have a type attribute that is different between these, but it wasn't an issue in 4.3 or below.
Andrew Branch (@andrewbranch) This updated example actually looks like a bug of the #44730 feature. a.isLargeBox() || b.isLargeBox() should not narrow a to LargeBox.
Ignore that. The issue is that LargeBox and SmallBox are structurally equivalent. If you add properties to each making them distinguishable you get the behaviour you expect.
Aalok Shah (@pomack) Everything that LargeBox says it provides is present in SmallBox as well. And because TypeScript is structurally typed this essentially means that everySmallBox is also a LargeBox. So if you check for LargeBox you end up with never in the else-branch, because every SmallBox is a LargeBox, and it's not a LargeBox, so it can't be a SmallBox either.
This is why I personally prefer to be explicit using discriminated unions:
Activity
MartinJohns commentedon Aug 31, 2021
This is the correct behaviour. Your variable

bis not typedSmallBox | LargeBox, it is typedLargeBox. TypeScript correctly sees that the valueisLargeBoxis alwaystrue, and as a result you end up withneverin theelse-branch.You see the behaviour you expect if you type
basSmallBox | LargeBox, e.g. using a type-assertion:const b = { ...} as SmallBox | LargeBox.You have the same behaviour in TypeScript 4.3.5 when you replace
isLargeBoxwithb.isLargeBox(). TypeScript 4.3.5 did not have the feature to narrow indirectly via local variables: #44730 - You can opt out from this feature by adding an explicitbooleantype annotation to your variable:const isLargeBox: boolean = b.isLargeBox();MartinJohns commentedon Aug 31, 2021
That
bis narrowed toLargeBoxdespite your explicit type annotationSmallBox | LargeBoxis intentional as well. Assigning a value to a variable narrows the variable to the assigned type. Otherwise code like this would not work:andrewbranch commentedon Aug 31, 2021
To clarify, it sees this not because of the
return truein the object literal initializer; it sees this because of everything else Martin Johns (@MartinJohns) explained; thatbis immediately narrowed toLargeBoxand then it simply looks atthis is LargeBoxand says “yep, it sure is.”ilogico commentedon Aug 31, 2021
I don't think TypeScript sees
isLargeBoxalways returns true.Instead, I think it sees
sayHello()returnnumber, makingbaLargeBox, which meansbhas an impossible type in theelsestatement.You can validate this by checking that
b.sayHello()returnsnumberbefore any narrowing. Butb.isLargeBox()is still described as returning an open value:this is LargeBox. TypeScript doesn't look into the function's implementation to see if it always returntrueor not.pomack commentedon Sep 1, 2021
Looks like I have the wrong minimal testcase. This is what I intended to do, let me know if I should open a different issue:
In both functions, the second
a.sayHello()hasaasnever.As a workaround, i can have a type attribute that is different between these, but it wasn't an issue in 4.3 or below.
MartinJohns commentedon Sep 1, 2021
Andrew Branch (@andrewbranch) This updated example actually looks like a bug of the #44730 feature.a.isLargeBox() || b.isLargeBox()should not narrowatoLargeBox.Ignore that. The issue is that
LargeBoxandSmallBoxare structurally equivalent. If you add properties to each making them distinguishable you get the behaviour you expect.Aalok Shah (@pomack) Everything that
LargeBoxsays it provides is present inSmallBoxas well. And because TypeScript is structurally typed this essentially means that everySmallBoxis also aLargeBox. So if you check forLargeBoxyou end up withneverin theelse-branch, because everySmallBoxis aLargeBox, and it's not aLargeBox, so it can't be aSmallBoxeither.This is why I personally prefer to be explicit using discriminated unions:
Here you have a dedicated property to explicitly distinguish the different types.
pomack commentedon Sep 1, 2021
Thanks Martin Johns (@MartinJohns) Will add the explicit attribute.