Skip to content
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

Indexed access behavior differs between type aliases and type parameters. #16036

Closed
hearnden opened this issue May 23, 2017 · 5 comments
Closed
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed Fixed A PR has been merged for this issue

Comments

@hearnden
Copy link

TypeScript Version: 2.3.2

Summary: Tagged<Opts>[keyof Opts] evaluates differently depending on whether Opts is a type alias or a type parameter.

Code

// Given an index type:
type MyOpts = {
  x: number,
  y: string,
};

// pair each value with its index tag:
type Tagged<T> = { [K in keyof T]: { tag: K, val: T[K] } };
// which produces:
//   Tagged<MyOpts> == {
//     x: { tag: 'x', val: number },
//     y: { tag: 'y', val: string }
//   }

// Then project the signature of that mapped type to get a tagged/disjoint union:
type MyTaggedUnion1 = Tagged<MyOpts>[keyof MyOpts];
// which produces:
//   MyTaggedUnion1 = { tag: 'x', val: number } | { tag: 'y', val: string }
// which is exactly what we want.

// But when trying to express Tagged<MyOpts>[keyof MyOpts] as a generic type expression,
// the result turns out to be not as specific:
type TaggedUnion<Opts> = Tagged<Opts>[keyof Opts];
type MyTaggedUnion2 = TaggedUnion<MyOpts>;
// produces:
//   MyTaggedUnion2 = { tag: 'x' | 'y', val: number | string }

Playground link

Expected behavior:
Tagged<O>[keyof O] should produce the same thing, whether O is an alias to a type literal or the name of a type parameter.

Actual behavior:
The literal version produces the desirable result:

{ tag: 'x', val: number } | { tag: 'y', val: string }

The parameterized version produces the weaker result:

{ tag: 'x' | 'y', val: number | string }

Possibly related: #15983

@hearnden
Copy link
Author

Define Merged<T> of an indexed type as the union of its property types:

type Merged<T> = T[keyof T];

such that a tagged union of T's properties can be expressed as Merged<Tagged<T>>:

type MyTaggedUnion3 = Merged<Tagged<MyOpts>>;

evaluates as expected to: { tag: "x"; val: number; } | { tag: "y"; val: string; }

But when Merged<Tagged<_>> is expressed as a single operator:

type MergedTagged<T> = Merged<Tagged<T>>;

then:

type MyTaggedUnion4 = MergedTagged<MyOpts>; 

evaluates unexpectedly to: { tag: "x" | "y"; val: number | string; }.

@hearnden
Copy link
Author

hearnden commented May 24, 2017

I have a hunch as to why this is happening.

The evaluation of the literal expression Mapped<Tagged<MyOpts>> perhaps proceeds as follows. A literal is equivalent to an intersection of indexed types, one for each property:

type MyOpts = { x: number, y: string }

is the same (I presume?) as:

type MyOpts = { [k in 'x']: number } & { [k in 'y']: string };

The type expression Tagged<MyOpts> sees its argument as a product of indexed types, and the application of the mapped-type expression { [K in keyof T]: ⟦ E ⟧ } to that product is perhaps evaluated to a product of the mappings:

Tagged<MyOpts>
==> Tagged<{ x: number, y: string }>
==> Tagged<{ [k in 'x']: number } & { [k in 'y']:  string }>;
==> Tagged<{ [k in 'x']: number }> & Tagged<{ [k in 'y']: string }>;
==> { [k in 'x']: ... } & { [k in 'y']:  ... };
==> { x: ..., y: ... }

The next type expression, Merged<_>, also sees a product of indexed types, and the mapped-type expression that defines Merged is perhaps evaluated in the same way:

Merged<{ x: ..., y: ... }>
==> Merged<{ [k in 'x']:  ... } & { [k in 'y']: ... }>;
==> ({ [k in 'x']:  ... } & { [k in 'y']: ... })[ 'x' | 'y' ];
==> { [k in 'x']: ... }['x'] | { [k in 'y']:  ... }['y'];
==> { ... } | { ... }

But the body of a type operator's expression is perhaps evaluated differently.

type MergedTagged<T> = Merged<Tagged<T>>;

If T is assumed to have a shape that's no more specific than a single indexed expression: { [K in keyof T]: T[K] }, then:

Merged<Tagged<{ [K in keyof T]: T[K] }>>
==> Merged<{ [K in keyof T]: { tag: K, val: T[K] } }>
==> { [K in keyof T]: { tag: K, val: T[K] } }[keyof T]
==> { tag: K, val: T[K] }

so when MergedTagged<_> is applied to { [k in 'x']: number } & { [k in 'y']: string }, the product of indexed expressions gets widened into a single indexed expression { [k in 'x'|'y']: string|number }:

MergedTagged<{ [k in 'x']: number } & { [k in 'y']:  string }>
==> MergedTagged<{ [k in 'x'|'y']: string|number }>
==> Merged<Tagged<{ [k in 'x'|'y']: string|number }>>
==> { tag: 'x'|'y', val: string|number }

If that's true, or even close to the mark, then I don't know if this still counts as a bug or not. But if plain type parameters <T> are secretly being constrained in ways that type literals are not (e.g., widened into a single indexed type?), then maybe it still counts as a bug?

@RyanCavanaugh
Copy link
Member

@ahejlsberg any idea what's going on here?

@ahejlsberg
Copy link
Member

This is due to one of our higher order mapped type relationships (#12351). Specifically, we have a rule that says a type { [P in K]: T}[X] is equivalent to an instantiation of T where X is substituted for every occurrence of P. An example of where we rely on this rule is:

function setState<T, K extends keyof T>(obj: T, props: Pick<T, K>) {
    for (let k in props) {
        obj[k] = props[k];  // Pick<T, K>[K] is equivalent to T[K]
    }
}

The reason you get different types from Tagged<MyOpts>[keyof MyOpts] versus TaggedUnion<MyOpts> is that the compiler doesn't "see" the relationship in the first construct because Tagged<MyOpts> and keyof MyOpts are eagerly instantiated. However, because TaggedUnion uses a type parameter, the instantiation is deferred and the equivalence becomes observable.

You can get the desired result by intersecting with a dummy index signature:

type TaggedUnion<Opts> = (Tagged<Opts> & { [x: string]: any })[keyof Opts];

The index signature will never come into play, so its only effect is to disable the higher order equivalence pattern.

@mhegazy mhegazy added the Design Limitation Constraints of the existing architecture prevent this from being fixed label May 30, 2017
@ahejlsberg
Copy link
Member

Fixed by #18042.

@ahejlsberg ahejlsberg added the Fixed A PR has been merged for this issue label Aug 25, 2017
@microsoft microsoft locked and limited conversation to collaborators Jun 14, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed Fixed A PR has been merged for this issue
Projects
None yet
Development

No branches or pull requests

4 participants