-
Notifications
You must be signed in to change notification settings - Fork 13k
Closed
Labels
Working as IntendedThe behavior described is the intended behavior; this is not a bugThe behavior described is the intended behavior; this is not a bug
Description
TypeScript Version: 3.5.2
Search Terms: generic constraint constrained index lookup function union
Code
type MyRecord = {
foo: { foo: number };
bar: { bar: number };
};
type Tag = keyof MyRecord;
type MyUnion = MyRecord[Tag];
declare const values: {
foo: MyRecord['foo'];
bar: MyRecord['bar'];
};
declare const fns: {
foo: () => MyRecord['foo'];
bar: () => MyRecord['bar'];
};
declare const predicates: {
foo: (value: MyUnion) => value is MyRecord['foo'];
bar: (value: MyUnion) => value is MyRecord['bar'];
};
<T extends Tag>(tag: T) => {
// 1.
{
const value = values[tag];
type ExpectedType = MyRecord[T];
// Expected: no error
// 3.4 and 3.5: works as expected
const test: ExpectedType = value;
}
// 2.
{
declare const value: MyUnion;
// Expected: error
// 3.4: no error
// 3.5: works as expected
const desired: MyRecord[T] = value;
}
// 3.
{
const fn = fns[tag];
type ExpectedType = () => MyRecord[T];
// Expected: no error
// 3.4: works as expected
// 3.5: unexpected error
const test: ExpectedType = fn;
}
// 4. (this is our real world use case)
{
declare const value: MyUnion;
const predicate = predicates[tag];
if (predicate(value)) {
// Expected: no error
// 3.4: works as error
// 3.5: unexpected error
const desired: MyRecord[T] = value;
}
}
};
Metadata
Metadata
Assignees
Labels
Working as IntendedThe behavior described is the intended behavior; this is not a bugThe behavior described is the intended behavior; this is not a bug
Type
Projects
Milestone
Relationships
Development
Select code repository
Activity
OliverJAsh commentedon Jun 21, 2019
Could this be related to #30769?
It seems 3.5 fixed something which made example 2 (above) begin failing as it always should have, but this change in turn meant that examples 3 and 4 started to fail (which I believe they shouldn't).
ahejlsberg commentedon Jun 21, 2019
Yes, this is an effect of #30769. In example 4 you're assigning a
{ foo: number } | { bar: number }
to aMyRecord[T]
. That may be correct (which was as far as 3.4 checked it), but it isn't known to be correct (which is what 3.5 checks). For example, it isn't correct ifvalue
is a{ foo: number }
andT
is"bar"
.OliverJAsh commentedon Jun 21, 2019
In example 4, shouldn't
value
be narrowed toMyRecord[T]
? The reason I think that is because we index intopredicates
withT
, which should mean the resultingpredicate
's type predicate will matchMyRecord[T]
.IIUC, that should be impossible, because the
predicate
type predicate would always correspond toT
. For example, ifT
is"bar"
,value
would always be{ bar: number }
.(Sorry if I'm being slow here, and thanks so much for your help!)
jack-williams commentedon Jun 21, 2019
In example 4 you're trying to narrowing something concrete to something generic using concrete predicates. In general TypeScript will never produce something generic from something concrete.
You're implicitly assuming assuming that there is a concrete lower bound on
T
; that is, there are no types assignable to"foo" | "bar"
other than"foo"
or"bar"
, and that also have values (so notnever
).Personally I think this is dubious reasoning, but that is besides the point because TypeScript doesn't do this reasoning anyway.
I think the way to write this would be to pass in a generic predicate function:
karol-majewski commentedon Jun 24, 2019
@jack-williams Could you please elaborate why this is dubious reasoning?
What else then
'foo'
'bar'
'foo' | 'bar'
never
'foo' & 'bar'
(which is alsonever
since Empty intersection reduction #31838, just like any other intersection in this case)is assignable to
"foo" | "bar"
?OliverJAsh commentedon Jun 25, 2019
I'm also curious to know the answer to @karol-majewski's question.
@jack-williams
Can't the same be said for example 1? IIUC, the only difference is instead of concrete predicates/functions we have concrete values.
Unfortunately this workaround would defeat the point of our function, which is: given a tag from a tagged union, pass the given union value into a given function if the value has the required tag.
For context, here is our real world use case in full:
ahejlsberg commentedon Jun 26, 2019
Using the names
Foo
andBar
for{ foo: number }
and{ bar: number }
, yourpredicate
variable has typebut that is not the same as
which is the type
predicate
would need in order to narrowvalue
to{ foo: Foo, bar: Bar }[T]
. You need a type assertion to convince the checker:Without the type assertion, in the call
predicate(value)
the checker uses the constraint ofT
(i.e.'foo' | 'bar'
) and ends up with the type(value: Foo | Bar) => value is Foo | Bar
, which narrowsvalue
to the type it already has.The reason it worked in 3.4 is the unsound checking we previously did for assignments to indexed access types (i.e. we'd allow
Foo | Bar
to be assigned to{ foo: Foo, bar: Bar}[T]
).OliverJAsh commentedon Jun 27, 2019
Thanks for the helpful reply @ahejlsberg.
I'm struggling to see why the above two types can't be proved to be the same.
T
is'foo'
, they would both "end up" as(value: Foo | Bar) => value is Foo
.T
is'bar'
, they would both "end up" as(value: Foo | Bar) => value is Bar
.T
is'foo' | 'bar'
, they would both "end up" as(value: Foo | Bar) => value is Foo | Bar
.Is there a case I'm missing where
T
is something else and in which case the above two types would not end up the same?typescript-bot commentedon Jun 29, 2019
This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.
OliverJAsh commentedon Jun 30, 2019
Hoping we can still get some response to #32017 (comment) before this issue becomes locked.
jack-williams commentedon Jul 1, 2019
@karol-majewski
I think it's dubious reasoning because it violates parametricity and makes assumptions that are not future compatible. As I've mentioned before (probably to the tedium of others), the assumption that the only subtypes of
"foo" | "bar"
are the ones you list does not hold under name-subtyping, which is some I'd like to see in the future.For example:
The function
swap
really should (IMO) be the function that returns a new tuple with the elements swapped. However, if you follow the reasoning that you propose it could implemented by a function that returns the input unmodified.Assuming that the type
"foo"
is denoted by a singleton set, rather than a set of uniformly behaving values, is unnecessarily transparent. IMO it just adds holes to the type-system that don't need to be there.RyanCavanaugh commentedon Jul 1, 2019
Even today
"foo" & { sketchy: "brand" }
is a subtype of"foo"
.karol-majewski commentedon Jul 1, 2019
@jack-williams @RyanCavanaugh thank you for your answers!
Just to make sure: is this explanation correct then? 👇
jack-williams commentedon Jul 2, 2019
@karol-majewski Yep
To elaborate: indexing using a
T
for something expecting a"foo"
is fine - it is the converse that will not work.