Skip to content

Lookup across a mapped type gives different result when using a generic alias #15756

Closed
@MatthewPopat

Description

@MatthewPopat

TypeScript Version: 2.3

I have been trying to create a generic type that maps the property names and types from one type to a discriminated union of key value pair types. I have found that using a lookup across a mapped type works fine as long as you use a concrete type before doing the lookup (see pair2 below). But if you try to create a generic type alias to do the lookup you end up with a different result (see pair1 below).

Code

type Pairs<T> = {
    [TKey in keyof T]: {
        key: TKey;
        value: T[TKey];
    };
};

type Pair<T> = Pairs<T>[keyof T];

type FooBar = {
    foo: string;
    bar: number;
};

let pair1: Pair<FooBar> = {
    key: "foo",
    value: 3
}; // no compile error here

let pair2: Pairs<FooBar>[keyof FooBar] = {
    key: "foo",
    value: 3
}; // this does cause a compile error

Expected behavior:

Both pair1 and pair2 above should cause a compile error.

Actual behavior:

pair1 seems to be assigned the type:

{
    key: "foo" | "bar";
    value: string | number;
}

whereas pair2 is assigned the type I would expect:

{
    key: "foo";
    value: string;
} | {
    key: "bar";
    value: number
}

Activity

jaen

jaen commented on Jul 15, 2017

@jaen

I've just been bitten by that issue trying to type redux reducers generically.
A simplified version of my problem is as follows:

// a type which maps action kind string literal to payload type
type Simple = {
  setField: { field: string, value: string },
  submit:   { param: string }
};

// transform the above to a mapping from action kind to what I would emit in redux
type ActionTypesDictionary<Actions> =
  ({ [Key in keyof Actions]: { kind: Key, payload: Actions[Key]} });

// turns a mapping into a union of its values
type DictionaryToUnion<Dictionary> =
  Dictionary[keyof Dictionary];

// using the type explicitly works, but is boilerplate
type Test = ActionTypesDictionary<Simple>[keyof Simple];
//   { kind: "setField"; payload: { field: string; value: string; }; }
// | { kind: "submit"; payload: { param: string; }; }

// this is where it goes wrong - parameterisng over type to hide the boilerplate
type ActionTypes<Actions> = ActionTypesDictionary<Actions>[keyof Actions];

// results in incorrect type being inferred
type Test2 = ActionTypes<Simple>;
// type Test2 = { 
//   kind: "setField" | "submit";
//   payload: { field: string; value: string; } | {};
// }

It seems the mapping step (ActionTypesDictionary) works well on its own, as can be observed by the type Test when I want to abstract over the boilerplate of having to index the resulting dictionary manually by introducing parametricity the inference breaks. For some reason the disjoint union operator | get pushed down into the type in an unsound way, breaking the relation between kind and payload which enables incorrect mixing of payloads for a given kind of action.

Hopefully, this isn't hard to pin down why it happens, because it blocks typing a whole class of useful code.

NaridaL

NaridaL commented on Jul 15, 2017

@NaridaL
Contributor

Hovering over ActionTypes:

type ActionTypes<Actions> = {
    kind: keyof Actions;
    payload: Actions[keyof Actions];
}

which isn't a valid transformation.

jaen

jaen commented on Jul 15, 2017

@jaen

Right, maybe it's unclear where this exactly breaks down from my description.

To wit, the type generated by ActionTypesDictionary<Type> wen applied is correct:

type ActionTypesDictionary<Actions> = {
  [Key in keyof Actions]: {
    kind: Key;
    payload: Actions[Key];
  };
}

and mapping it by hand with Type[Keys] yields an intuitively correct type (an disjoint union of objects):

type ActionTypesDictionary<Simple>[keyof Simple] =
    { kind: "setField"; payload: { field: string; value: string; }; }
  | { kind: "submit"; payload: { param: string; }; }

while when the mapping is astracted over with a parametric type ActionTypes<Type> it produces a different, intuitively incorrect type (an object of disjoint unions) when applied:

type ActionTypes<Simple> = { 
  kind: "setField" | "submit";
  payload: { field: string; value: string; } | {};
}

so somehow wrapping the type indexing in a generic yields an incorrect transformation, because as @NeridaL shown, the type of unapplied ActionTypes<Type> incorrectly propagates the indexing into the type, effectively turning:

({
  [Key in keyof Actions]: {
    kind: Key;
    payload: Actions[Key];
  };
})[keyof SomeType]

into:

{
    kind: keyof SomeType;
    payload: Actions[keyof SomeType];
}

as if keyof SomeType was some property name in keyof Actions.

Hope it's clearer now.

jaen

jaen commented on Jul 16, 2017

@jaen

I've traced this down to this commit and it's associated pull request.

Introduction of

if (isGenericMappedType(objectType)) {
    return getIndexedAccessForMappedType(<MappedType>objectType, indexType, accessNode);
}

to enable the features in the pull request caused the Alias<Type> to propagate the | down in an unsound way. While of course, the features enabled by the PR are beneficial, maybe this logic could be adjusted not to propagate the '|' into the type?

Or maybe I am missing something about how this should work?

EDIT: after looking at this some more I understand what's the motivation of this substitution better (it does add soundness in other cases such as f13 in mappedTypeRelationships).
It also seems that at this stage of inference the typechecker doesn't seem to have enough information to see that the indexType can be constrained somehow (the fields for constraints are empty). And indeed, if TypeScript typechecks generics at declaration site and not use site (I think it does, correct me if I'm wrong) then there's no way to provide additional constraints at this point.
That is unless unions and string literals would be raised to first-class citizens and could be referred to as for eg. type Generic<Param extends Union<StringLiteral>.
And even then, it would need some expansion and simplification step at use-site to perform a correct substitution at the point the type parameter is of known type.

jaen

jaen commented on Jul 22, 2017

@jaen

Thanks to @tycho01's comment here #16018 (comment) I was able to work around this issue using a generic default, as evidenced here:

// some type mapping action kind string literal to payload type
type Simple = {
  setField: { field: string, value: string },
  submit:   { param: string }
};

// transform the above to a mapping from action kind to what I would emit in redux
type ActionTypesDictionary<Actions> =
  ({ [Key in keyof Actions]: { kind: Key, payload: Actions[Key]} });

// turns a mapping into a union of its values
type DictionaryToUnion<Dictionary> =
  Dictionary[keyof Dictionary];

// using the type explicitly works, but is boilerplate
type Test = ActionTypesDictionary<Simple>[keyof Simple];
//   { kind: "setField"; payload: { field: string; value: string; }; }
// | { kind: "submit"; payload: { param: string; }; }

// introducing abstraction to hide the boilerplate
type ActionTypes<Actions> = ActionTypesDictionary<Actions>[keyof Actions];

// changes the inferred type
type Test2 = ActionTypes<Simple>;
// type Test2 = { 
//   kind: "setField" | "submit";
//   payload: { field: string; value: string; } | {};
// }

// using the generic default hack
type WorkingActionTypes<Actions, Temp extends ActionTypesDictionary<Actions> = ActionTypesDictionary<Actions>> = Temp[keyof Actions];

// makes it work again!
type Test3 = ActionTypes<Simple>;
//   { kind: "setField"; payload: { field: string; value: string; }; }
// | { kind: "submit"; payload: { param: string; }; }

I might be far off base, but if I understand what TypeScript compiler does correctly is that the generic default hack forces the indexed access typecheck to, instead of doing the type parameter substitution via a mapper, defer the typechecking and propagating the generic application as a constraint, which when WorkingActionTypes finally gets applied to an actual parameter, goes down the usual mapped type application in instantiateMappedType using this constraint, which has proper distributive union behaviour.

If fixing the substitution behaviour is hard/undesirable due to errors it prevents in other cases, then maybe some annotation to defer typechecking of the generic to the point of application that's not as hacky as generic default could be introduced? Something like (to be bikeshedded of course):

type ActionTypes<Actions> = DeferredGenericApplication<ActionTypesDictionary<Actions>>[keyof Actions];
KiaraGrouwstra

KiaraGrouwstra commented on Jul 23, 2017

@KiaraGrouwstra
Contributor

extends Union<StringLiteral>

Yeah, best we can do now is write like extends string /*union*/ or something... wouldn't that do though? I mean, I get why unions can't be expressed that way -- it's expected they would implicitly work everywhere. In practice that might not hold universally (could think of at least one (#17361) but really need to do more testing there) but yeah.

jaen

jaen commented on Jul 23, 2017

@jaen

Yeah, I've learned of extends string after that comment, but as far as I can tell that doesn't distinguish between an arbitrary string value string, a single literal "something" or union of those "something" | "else". I'm not quite sure how that looks in the compiler internals but on the surface, this seems to limit how I can constrain the generic.

KiaraGrouwstra

KiaraGrouwstra commented on Jul 23, 2017

@KiaraGrouwstra
Contributor

@jaen:
Yeah, you're right, we can't technically distinguish them. It appears just kind of implied that it'd be useless to use a generic when you're always expecting string instead of literals.

I agree we don't have a great way to communicate to users that you're expecting a union, so I'd just kinda try to reflect that in both the code and the naming, e.g. Keys extends string /*union*/.

I guess you're not really supposed to constrain input types in the sense that everything is supposed to be able to scale to applying en masse to a union of types.
In that line of thinking, I suppose anything where that distributivity law breaks would currently be considered a bug in union behavior (see e.g. the thread I edited into my post above).
I'm not really sure yet how easy this standard would be to live up to (should mostly just work?), but I do think I like the idea.

added
BugA bug in TypeScript
and removed
Needs InvestigationThis issue needs a team member to investigate its status.
on Aug 23, 2017

6 remaining items

ahejlsberg

ahejlsberg commented on Aug 25, 2017

@ahejlsberg
Member

@tycho01 Thanks for your contribution. I was separately working on a fix which I just put up. I think it is a more correct way of solving the issue. If you have a chance, could you verify that this fixes the problems you've been seeing?

KiaraGrouwstra

KiaraGrouwstra commented on Aug 25, 2017

@KiaraGrouwstra
Contributor

@ahejlsberg: now there's a pleasant surprise! I hadn't even checked my own original use-case anymore, but turns out it was already working on master again anyway. 😅
It looks like your version has fixed a bunch more as well, so no objections here!

added this to the TypeScript 2.6 milestone on Aug 25, 2017
jaen

jaen commented on Aug 26, 2017

@jaen

Just letting you know this also fixes my original issue, thanks a lot!

Incidentally, when I try to autocomplete the payload parameter with kind already filled in, the autocompletion seems to suggest fields from either case of union (even though the kind field should limit that). Is that an expected behaviour, or should I open a bug for it?

jcalz

jcalz commented on Aug 28, 2017

@jcalz
Contributor

EDIT: IGNORE THE FOLLOWING, issue is fixed in TS v2.6.0-dev.20170826


Now that the fix for this is in (#18042), I tried implementing (in TS v2.6.0-dev.20170824) the type function Transpose, which reverses a mapping of string literals. Example:

// which kinds of ice cream does each person like
type IceCreamPreferences = {
  'alice': 'vanilla' | 'chocolate' | 'strawberry';
  'bob': 'chocolate';
  'carol': 'strawberry' | 'rumRaisin';
  'dave': 'rumRaisin' | 'chocolate';
  'eve': 'tripleFudgeRipple';
}
 
// which people like each kind of ice cream
type TransposedIceCreamPreferences = {
  'vanilla': 'alice';
  'chocolate': 'alice' | 'bob' | 'dave';
  'strawberry': 'alice' | 'carol';
  'rumRaisin': 'carol' | 'dave';
  'tripleFudgeRipple': 'eve';
}

Here is a version of Transpose that still doesn't work:

// union of possible value types
type ValueOf<T> = T[keyof T];

// subtract unions of string literals
type Diff<T extends string, U extends string> = (
  {[K in T]: K} &
  {[K in U]: never} &
  { [K: string]: never }
)[T];

type Transpose<T extends Record<string, string>> = ValueOf<{
  [P in keyof T]: Record<Diff<ValueOf<T>, T[P]>, never> & Record<T[P], P>
}> // broken!  

type WhoLikes = Transpose<IceCreamPreferences>;
var chocolateLover: WhoLikes['chocolate'];
chocolateLover = 'alice'; // okay
chocolateLover = 'bob'; // okay
chocolateLover = 'carol'; // 🙁 should error, but doesn't! 
chocolateLover = 'dave'; // okay
chocolateLover = 'eve'; // 🙁 should error, but doesn't! 

Something is still doing an eager substitution where I don't expect it.

Luckily the default-generic workaround does work here:

type Transpose<T extends Record<string, string>, X = {
  [P in keyof T]: Record<Diff<ValueOf<T>, T[P]>, never> & Record<T[P], P>
}> = ValueOf<X> // works

type WhoLikes = Transpose<IceCreamPreferences>;
var chocolateLover: WhoLikes['chocolate'];
chocolateLover = 'alice'; // okay
chocolateLover = 'bob'; // okay
chocolateLover = 'carol'; // 🙂 error, carol doesn't like chocolate
chocolateLover = 'dave'; // okay
chocolateLover = 'eve'; // 🙂 error, eve doesn't like chocolate

So, the question is: is this the same issue or a new issue? If it's a new one, I'll open one for it. Also, are we dealing with a bug/limitation or is this how people want TS to behave?

KiaraGrouwstra

KiaraGrouwstra commented on Aug 28, 2017

@KiaraGrouwstra
Contributor

@jcalz: still looks like a bug, rather than desired behavior.
I'd recommend opening a new issue, as I'm not sure they'd still recheck this one after closing it.
Please link to it from here though, then we know which one to follow for this.

jcalz

jcalz commented on Aug 29, 2017

@jcalz
Contributor

Looks like I tested a version of TS without all the relevant commits. The above issue does not appear in 2.6.0-dev.20170826. Transpose is good to go!

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

Labels

BugA bug in TypeScriptFixedA PR has been merged for this issue

Type

No type

Projects

No projects

Relationships

None yet

    Development

    No branches or pull requests

      Participants

      @jcalz@jaen@KiaraGrouwstra@ahejlsberg@RyanCavanaugh

      Issue actions

        Lookup across a mapped type gives different result when using a generic alias · Issue #15756 · microsoft/TypeScript