-
Notifications
You must be signed in to change notification settings - Fork 12.8k
Improve type guard consistiency #5442
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
@@ -6338,6 +6338,10 @@ namespace ts { | |||
// Stop at the first containing function or module declaration | |||
break loop; | |||
} | |||
// Preserve old top-level behavior - if the branch is really an empty set, revert to prior type | |||
if (narrowedType === getUnionType(emptyArray)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't getUnionType(emptyArray) === emptyObjectType? Why not just use that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I respected the note left in the narrowing code about this - if we change how we represent empty unions, then this is the canonical way to find that representation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes sense.
What happens with conflicting guards with this change? You'll still get the original union, right? |
Correct - I moved the check to replace an empty object with the original type from within the narrowing functions to the top level of the |
if (assumeTrue) { | ||
// Assumed result is true. If check was not for a primitive type, remove all primitive types | ||
if (!typeInfo) { | ||
return removeTypesFromUnionType(type, /*typeKind*/ TypeFlags.StringLike | TypeFlags.NumberLike | TypeFlags.Boolean | TypeFlags.ESSymbol, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removeTypesFromUnionType
is only used one place now, I think. I bet you could considerably simplify the interface and implementation (typeKind
and isTypeOfKind
and allowEmptyUnionResult
at least).
Actually, the lone usage might also be replaceable by getUnionType . filter
as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems reasonable, I was thinking about the same thing - just wanted to avoid making the change cascade into too many other places. I'll replace the invocation.
The change is convincing to me -- seems like a small but reasonable change to the semantics -- so here's a tentative 👍 |
} | ||
} | ||
|
||
// Preserve old top-level behavior - if the branch is really an empty set, revert to prior type | ||
if (type === getUnionType(emptyArray)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just check against the emptyObjectType
, or if you want to avoid tying this code against the fact that it will return the emptyObjectType
, calculate it once at the top.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually I might declare an emptyUnionType
which is just aliased to emptyObjectType
, then return it in getUnionType
and use it here
@@ -112,6 +112,7 @@ namespace ts { | |||
let circularType = createIntrinsicType(TypeFlags.Any, "__circular__"); | |||
|
|||
let emptyObjectType = createAnonymousType(undefined, emptySymbols, emptyArray, emptyArray, undefined, undefined); | |||
let emptyUnionType = emptyObjectType; | |||
let emptyGenericType = <GenericType><ObjectType>createAnonymousType(undefined, emptySymbols, emptyArray, emptyArray, undefined, undefined); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you make all of these const
s?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can I remove circularType
while I'm at it? (It's referenced nowhere)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do as your heart tells you. ✨
@sandersn @DanielRosenwasser @mhegazy is there anything else you'd like from this PR, or can it be merged? |
I've run perf tests (10 iterations) against this branch vs master:
I don't see any meaningful change in perf. |
Can we merge this now? I talked to @mhegazy yesterday and we are both in favor of it. @DanielRosenwasser can you take another look? |
case SyntaxKind.MethodSignature: | ||
case SyntaxKind.GetAccessor: | ||
case SyntaxKind.SetAccessor: | ||
case SyntaxKind.Constructor: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should just check isFunctionLike
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isFunctionLike
also returns affirmatively for FunctionExpression
, ArrowFunction
, CallSignature
, ConstructSignature
, IndexSignature
, FunctionType
, and ConstructorType
- the difference visibly changes our behavior (as in: tests fail). I think these kinds are moreso to find "top-level" declarations where logic outside of it is unlikely to affect the type within the declaration.
Perf impact seems decently small, apart from my last comment 👍 |
Spoke with @DanielRosenwasser about that switch and the implications for changing it - opened #5619 as a related issue, and will merge this as-is since it matches existing behavior for selecting where to stop collecting type narrowing information (bugs included). |
Improve type guard consistiency
🎉 |
Building on what was mentioned in #5427, modifies
getNarrowedTypeOfSymbol
to handle an empty union only at the top level (so we handle compound expressions correctly), thereby allowing enabling returning empty sets at the correct time when mid-narrow.Additionally, I've also preserved the behavior for narrowing
any
by a primitive type, and incidentally fixed issues where narrowing wasn't being correctly applies to anelse
clause or with enums.Long story short, this fixes a bunch of bugs, but preserves our current behaviors in meaningful edge cases (according to our test suite).
Fixes #1270, #5439, #5100, #4874. (Or it should, anyway)
To summarize the changes:
narrowTypeByEquality
was mostly rewritten to unify the positive case and negative case logic, while also allowing it to actually return an empty union.removeTypesFromUnion
was removed in the process, and flags were set to not merge subtypes in unions when narrowing (thus preserving enums/future string types in narrowed unions). To preserve old (desired?) behavior with respect to type guards narrowing a type to the empty set acting as the original type, a check was added after the loop ingetNarrowedTypeOfSymbol
to do just that. While looking atgetNarrowedTypeOfSymbol
, I noticed we were narrowing from the outside in (for some reason), which was the root cause of nested narrowing not working as intended. To fix this, I instead create a stack of parent nodes and visit them in reverse order from before. This, combined with actually returning an empty set from the internal narrowing functions, corrected the tautological case.@sandersn how do you feel about it?
If you feel strongly about allowing narrowing to the empty set, we can now do that just by removing a top-level check in
getNarrowedTypeOfSymbol
.