-
Notifications
You must be signed in to change notification settings - Fork 12.6k
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
Lookup across a mapped type gives different result when using a generic alias #15756
Comments
I've just been bitten by that issue trying to type redux reducers generically. // 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 ( Hopefully, this isn't hard to pin down why it happens, because it blocks typing a whole class of useful code. |
Hovering over
which isn't a valid transformation. |
Right, maybe it's unclear where this exactly breaks down from my description. To wit, the type generated by type ActionTypesDictionary<Actions> = {
[Key in keyof Actions]: {
kind: Key;
payload: Actions[Key];
};
} and mapping it by hand with 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 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 ({
[Key in keyof Actions]: {
kind: Key;
payload: Actions[Key];
};
})[keyof SomeType] into: {
kind: keyof SomeType;
payload: Actions[keyof SomeType];
} as if Hope it's clearer now. |
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 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 |
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 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):
|
Yeah, best we can do now is write like |
Yeah, I've learned of |
@jaen: 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. 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. |
@jaen: Thank again for figuring out what was going on over here; I just caught up. If I may summarize my understanding of this issue now, the relation between 'generic mapped type' relation between If |
So my hunch is that instead of this: if (isGenericMappedType(objectType)) {
return getIndexedAccessForMappedType(<MappedType>objectType, indexType, accessNode);
} We should do something like this, using pseudo-code to create those generic types as I'm not sure how to do it. if (isGenericMappedType(objectType)) {
const innerType = `type Inner<A, B> = ${getIndexedAccessForMappedType(<MappedType>objectType, A | B)}`;
const outerType = `type Outer<A, B> = ${getIndexedAccessForMappedType(<MappedType>objectType, A)} | ${getIndexedAccessForMappedType(<MappedType>objectType, B)}`;
if (compareTypesAssignable(innerType, outerType)) {
return getIndexedAccessForMappedType(<MappedType>objectType, indexType, accessNode);
}
} Would anyone know how to do that part of how to declare the types? If so, I'd be curious if this might do the trick. I'm not confident the compiler can pull off that assignability check there, and Murphy's law isn't in favor of my untested pseudo-code, but yeah. |
This incidentally also uncovered one other case where generic mapped types had been applied in an unsound way w.r.t. union keys.
Opened a PR at #18036. |
@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? |
@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. 😅 |
Just letting you know this also fixes my original issue, thanks a lot! Incidentally, when I try to autocomplete the |
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 // 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 // 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? |
@jcalz: still looks like a bug, rather than desired behavior. |
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. |
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 (seepair1
below).Code
Expected behavior:
Both
pair1
andpair2
above should cause a compile error.Actual behavior:
pair1
seems to be assigned the type:whereas
pair2
is assigned the type I would expect:The text was updated successfully, but these errors were encountered: