Closed
Description
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
}
Metadata
Metadata
Assignees
Type
Projects
Relationships
Development
No branches or pull requests
Activity
jaen commentedon Jul 15, 2017
I've just been bitten by that issue trying to type redux reducers generically.
A simplified version of my problem is as follows:
It seems the mapping step (
ActionTypesDictionary
) works well on its own, as can be observed by the typeTest
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 betweenkind
andpayload
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 commentedon Jul 15, 2017
Hovering over
ActionTypes
:which isn't a valid transformation.
jaen commentedon Jul 15, 2017
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:and mapping it by hand with
Type[Keys]
yields an intuitively correct type (an disjoint union of objects):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: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:into:
as if
keyof SomeType
was some property name inkeyof Actions
.Hope it's clearer now.
jaen commentedon Jul 16, 2017
I've traced this down to this commit and it's associated pull request.
Introduction of
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
inmappedTypeRelationships
).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 commentedon Jul 22, 2017
Thanks to @tycho01's comment here #16018 (comment) I was able to work around this issue using a generic default, as evidenced here:
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 ininstantiateMappedType
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):
KiaraGrouwstra commentedon Jul 23, 2017
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 commentedon Jul 23, 2017
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 valuestring
, 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 commentedon Jul 23, 2017
@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.
6 remaining items
ahejlsberg commentedon Aug 25, 2017
@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 commentedon Aug 25, 2017
@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!
jaen commentedon Aug 26, 2017
Just letting you know this also fixes my original issue, thanks a lot!
Incidentally, when I try to autocomplete the
payload
parameter withkind
already filled in, the autocompletion seems to suggest fields from either case of union (even though thekind
field should limit that). Is that an expected behaviour, or should I open a bug for it?jcalz commentedon Aug 28, 2017
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:Here is a version of
Transpose
that still doesn't work:Something is still doing an eager substitution where I don't expect it.
Luckily the default-generic workaround does work here:
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 commentedon Aug 28, 2017
@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 commentedon Aug 29, 2017
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!