Skip to content

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

Closed

Description

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

Activity

  1. hearnden commented on May 24, 2017

    @hearnden
    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; }.

  2. hearnden commented on May 24, 2017

    @hearnden
    Author

    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?

  3. RyanCavanaugh commented on May 30, 2017

    @RyanCavanaugh
    Member

    Anders Hejlsberg (@ahejlsberg) any idea what's going on here?

  4. ahejlsberg commented on May 30, 2017

    @ahejlsberg
    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.

  5. ahejlsberg commented on Aug 25, 2017

    @ahejlsberg
    Member

    Fixed by #18042.

  6. locked and limited conversation to collaborators on Jun 14, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Design LimitationConstraints of the existing architecture prevent this from being fixedFixedA PR has been merged for this issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions