-
Notifications
You must be signed in to change notification settings - Fork 12.6k
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
Fix narrowing union with union type predicate #31206
Conversation
if (type.flags & TypeFlags.Union) { | ||
const assignableType = filterType(type, t => isRelated(t, candidate)); | ||
if (!(assignableType.flags & TypeFlags.Never)) { | ||
const assignableType = candidate.flags & TypeFlags.Union |
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.
Why not define it recursively?:
if (candidate.flags & TypeFlags.Union) {
return mapType(candidate, t => getNarrowedType(type, t, assumeTrue, isRelated);
}
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's what I tried first, but the intersection logic at the end screws up the computation for object types. If two constituents are entirely not related, we want to drop them out of the union, not include their intersection.
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.
Wait, why?
let a: {x: number} | { y: string };
declare function isEightOrString(n: any): n is {x: 8} | {x: string}
if (isEightOrString(a)) {
a; // Should totally be `({ x: number; } & { x: 8; }) | ({ x: number; } & { x: string; }) | ({ y: string; } & { x: 8; }) | ({ y: string; } & { x: string; })` even though `{x: string}` and `{ y: string }` are unrelated (and yes, the first one of those could supertype reduce to just `{ x: 8; }`)
}
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.
Yeah... I see that's technically correct, but we have several baselines that expect unrelated constituents to be discarded. From partiallyDiscriminatedUnions.ts
:
class Square { kind: "square"; }
class Circle { kind: "circle"; }
type Shape = Circle | Square;
type Shapes = Shape | Array<Shape>;
declare function isShape(s : Shapes): s is Shape;
if (isShape(s)) {
s; // Before: Shape
// With recursive `getNarrowedType()`: Square | Circle | (Square & Shape[]) | (Circle & Shape[])
if (s.kind === "circle") {
s; // Before: Circle.
// With recursive `getNarrowedType()`: Circle | (Circle & Shape[])
}
}
And there's nothing technically incorrect about that, but it's definitively grosser, and we've been rolling with the current behavior for 3 years and nobody has complained.
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.
The above example I wrote behaves as I described today, though (you can put it in the playground and it yields exactly that type). And the "unrelated variants are discarded" shtick is mostly limited solely to discriminable variants (excepting in
narrowing, which we know is unsafe) - types whose member types guarantee that membership to one or the other is mutually exclusive.
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.
And the "unrelated variants are discarded" shtick is mostly limited solely to discriminable variants
What do you think about the Shape example then? Technically Circle
and Shape[]
aren't discriminable because I can define an object that conforms to both, but in practice it seems pretty unlikely.
There are a few other baselines that fail in the same vein, and it's even more painful when the result is an intersection between a primitive literal type and some type of object:
type S = "a" | "b";
type T = S[] | S;
function isS(t: T): t is S {
return t === "a" || t === "b";
}
function f(foo: T) {
if (isS(foo)) {
return foo; // Before: S
// After: "a" | "b" | ("a" & S[]) | ("b" & S[])
}
}
I can suspend my disbelief that I might have an object that is simultaneously a Circle
and Shape[]
, but "a" & S[]
? 🤨
It feels like there must be a case for using heuristics to simplify the results when a user is explicitly narrowing something. Otherwise, this function would just return the intersection no matter what, wouldn't it?
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.
Otherwise, this function would just return the intersection no matter what, wouldn't it?
Intersections don't actually do supertrype reduction, so we'd still be testing to see if one side is the supertype of another and simplifying to the supertype.
But pretty much, yeah.
but
"a" & S[]
Counterexample, we have branded literals:
type Brand<TString extends string, TBrand extends object> = TString & TBrand; // Have seen this type in the wild
type PathBrand = { __path: void; };
type Path<T extends string = string> = Brand<T, PathBrand>;
declare function isPathOrEmpty(x: any): x is PathBrand | ""; // checks if string is path-y or empty
function f(foo: "/usr/home" | "/tmp") { // literals used directly instead of branded versions
if (isPathOrEmpty(foo)) {
return foo; // Should definitely be `("/usr/home" & PathBrand) | ("/tmp" & PathBrand)`
// But `PathBrand` is "totally unrelated" to "/tmp"
}
}
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.
Sure, of course you're still right. It just feels bad 😄
I'm not sure what I would really propose here. I think most of these results appear really unintuitive, but I guess they don’t stop you from using the variable the way you would before.
And it catches an actual bug bug. Wow.
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've updated to this approach. You can see the handful of baselines change that look like these examples.
@@ -102,9 +108,13 @@ tests/cases/conformance/expressions/typeGuards/typeGuardsWithInstanceOfByConstru | |||
obj5.foo; | |||
obj5.c; | |||
obj5.bar1; | |||
~~~~ | |||
!!! error TS2339: Property 'bar1' does not exist on type 'C1 | C2'. | |||
!!! error TS2339: Property 'bar1' does not exist on type 'C2'. |
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.
So this has always been unsafe, and just now gets caught 😅
This change actually created a compilation error in TypeScript’s source itself: Before After Both We should probably see how much real-world code breaks with this. @weswigham isn't there a way to trigger a test like that? (Pushed the wrong button 👇 ) |
@typescript-bot test this |
Heya @weswigham, I've started to run the parallelized Definitely Typed test suite on this PR at 9733060. You can monitor the build here. It should now contribute to this PR's status checks. |
Heya @weswigham, I've started to run the extended test suite on this PR at 9733060. You can monitor the build here. It should now contribute to this PR's status checks. |
RWC looks like it has some unrelated diffs that are making it difficult to assess impact there. At a minimum we should delay this fix by a release since we're trying to have 3.5 have as few breaking changes as possible. To the general point of what constitutes a correct narrowing and what doesn't, in general we disavow the existence of some arbitrary It feels like there should be a more targeted fix for the original problem reported, but maybe not. |
I don't think this is true - we have a set number of scenarios and operators where we intentionally pretend that's the case, like |
We're certainly inconsistent and can behave either way depending on context, but there are core scenarios like |
That scenario uses comparability to determine "correctness" which is even more relaxed than assignability, though - even a single property of overlap will make that check allowable (while the types will won't be assignable in either direction). It's a different trade-off. |
We discussed this in a design meeting and decided there’s not currently a solution that’s worth the disruption it will bring. A hypothetical future where we can stop treating “branded primitives” as they exist today (e.g. I may look into doing the correct thing when all constituents of both the original type and the predicate type are primitives, since intersections of those are more intuitive and fall out when empty. On the other hand, I’m not sure if we want that kind of logical branching—maybe it’s better just to keep it in its current slightly wrong but consistent state. At any rate, this PR is out of scope for that kind of change. |
Fixes #31156
Previously, narrowing any union type started by filtering constituents of the original union out:
If any constituents matched, they were returned immediately.
Sometimes the candidate is more specific than the type you started with, so you can’t get anywhere by filtering from the original:
This worked because filtering down from
number | string
failed, and the intersection8 & (number | string)
was used as a fallback, which reduces to8
—a correct match.But, if the candidate had a subtype constituent like
8
and also had something that caused filtering down fromnumber | string
to succeed, then we’d never see8
in the resulting union:To fix, when looking through target constituents, we see if any candidate constituents are subtypes of the current target constituent, and if so, replace that target constituent with the subtype candidate constituents. This ensures we don’t stop finding matches too early, and also that we pick the narrowest matches possible.