-
Notifications
You must be signed in to change notification settings - Fork 12.8k
Pragmatic yet more correct narrowing algorithm for UDTP #47389
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
Comments
Iβm not really sure what youβre asking for, but |
I guess the thing Iβm having a hard time understanding is this: TypeScript types are defined in terms of assignability. |
I'm asking for narrowing
When did I say it was? Ofc it's not an empty type. I specifically mentioned "pragmatic intersect"
Why would you want to define it in terms of assignability? To make it clear: I'm not proposing a new type construct, "pragmatic intersect" is just an internal operation the compiler would perform while narrowing a type passed through a UDTP. |
Can you expand on this? It's not clear to me what the putatively unsound code snippet is. |
This is snippet I'm talking about... const foo = (x: { a: string | undefined } | { b: string }) => {
if (areValuesDefined(x)) {
x;
// ^? { b: string }
console.log(x.b.toUpperCase())
}
}
const areValuesDefined = <T>(x: T): x is { [K in keyof T]: Exclude<T[K], undefined> } =>
Object.values(x).every(x => x !== undefined)
foo({ a: "hello" }) // TypeError: Cannot read properties of undefined (reading 'toUpperCase') If the narrowed
Is that what you're asking? |
Hmm, hovering over const areValuesDefined: <{
a: string | undefined;
} | {
b: string;
}>(x: {
a: string | undefined;
} | {
b: string;
}) => x is {
a: string;
} | {
b: string;
} ...which is correct and is the narrowing behavior you want, but Even more curious is that disabling
I'm going to go out on a limb here and say this is a bona fide bug. |
@fatcerberus This is the expected behavior per #47283 (comment) |
|
I think I see the problem--the filtering is being done on the original union (essentially taking only the members the input and output types have in common), rather than the narrowed one. Would it produce more sound results if the logic was tweaked like this:
i.e. still "filter-first", but now you're doing the filtering on the narrowed-to type. |
It seems possible there's a good tweak here. The only way we find breaks in this area is to just write the PR and run the user code through it, since these rules were largely derived experimentally and bad effects show up pretty quickly in the user code suite. |
Looking at Also I've improved the proposed algorithm a bit... - keyof T & keyof N extends never
- ? never
- : T & N
+ T extends N
+ ? T
+ : keyof T & keyof N extends never
+ ? never
+ : T & N This will yield even better results type Test = DevanshNarrow<
{ a: string | undefined } | { b: string, foo: string },
{ a: string } | { b: string }
>
// Before:
// | ({ a: string | undefined } & { a: string })
// | ({ b: string, foo: string } & { b: string })
//
// After:
// | ({ a: string | undefined } & { a: string })
// | { b: stirng, foo: string } |
Looks like the narrowing is sound for this snippet in v4.8.4 (was unsound in v4.7.4, versions not exact). It used to narrow const foo = (x: { a: string | undefined } | { b: string }) => {
if (areValuesDefined(x)) {
// @ts-expect-error
console.log(x.b.toUpperCase())
}
}
const areValuesDefined = <T>(x: T): x is { [K in keyof T]: Exclude<T[K], undefined> } =>
Object.values(x as {}).every(x => x !== undefined) I wonder what was the change, whether it was intentional, and if this feature-request is still applicable (as this was the only unsound snippet I had in my mind xD). I think this can be closed, I'll open a new one if I come across another unsound suboptimal narrowing. |
Suggestion
π Search Terms
Narrowing, User defined typed predicate, first-filter narrowing
β Viability Checklist
β Suggestion
Let's take an example...
Okay so first off all, by definition of "narrowing", the narrowed
catOrDog
should have been(Cat | Dog) & (Cat | Fish)
and notCat
, both are not same. But TypeScript makes a good pragmatic decision to make itCat
, and how does it do that? Via an algorithm Ryan describes here. To describe it better let me convert that algorithm into code...The first three cases are good, the last one is problematic. Consider this...
Here
x
gets narrowed to{ b: string }
(instead of something like{ a: string } | { b: string }
) because of the current algorithm and makes the type system unsound.So instead of the current algorithm I propose this algorithm...
So the first three results remain the same, hence it's still pragmatic but now the last result is more correct with
DevanshNarrow
thanTypeScriptNarrow
. With this algorithm the unsound code above would not compile.The idea is instead of an actual intersection, we do a pragmatic intersection that is if two types don't overlap their pragmatic intersection is
never
. And henceDog <pragmaticIntersect> Cat
becomesnever
I wonder if there are cases where TypeScript's current algorithm yields better results than the proposed.
π Motivating Example
π» Use Cases
The text was updated successfully, but these errors were encountered: