You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Can't remap tuple keys to object keys in mapped typesΒ #55762
declareclassDecoder<T>{decode(arrayBuffer: ArrayBuffer): T;}typeValueTypeOf<TextendsDecoder<any>>=TextendsDecoder<infer R> ? R : never;typeStructDescriptor=ReadonlyArray<readonly[key: string,type: Decoder<any>]>;typeStructTypeFor<DescriptorextendsStructDescriptor>={[Kin(keyofDescriptor)&numberasDescriptor[K][0]]: ValueTypeOf<Descriptor[K][1]>;};classStructDecoder<constDescriptorextendsStructDescriptor>extendsDecoder<StructTypeFor<Descriptor>>{constructor(descriptor: Descriptor){super();}}declareconsti32Decoder: Decoder<number>;declareconsti64Decoder: Decoder<bigint>;conststructDecoder=newStructDecoder([["a",i32Decoder],["b",i64Decoder],]);conststruct=structDecoder.decode(newArrayBuffer(100));// I expected this would work, but it does notconstv: number=struct.a;
π Actual behavior
The type of struct.a has type number | bigint, however it should only have type number.
π Expected behavior
struct.a should have type number (and similarly struct.b should have type bigint).
Additional information about the issue
This does work when using a record instead, however this isn't as useful as I want the descriptor to still be an array so I can iterate over it.
type StructTypeFor<Descriptor extends StructDescriptor> = {
- [K in (keyof Descriptor) & number as Descriptor[K][0]]: ValueTypeOf<Descriptor[K][1]>;+ [K in KeyofTuple<Descriptor> as Descriptor[K][0]]: ValueTypeOf<Descriptor[K][1]>;
};
We have a mapped type called StructTypeFor<Descriptor>. The return type of structDecoder.decode(...), i.e. the type of struct, is that mapped type instantiated with Descriptor = readonly [readonly ["a", Decoder<number>], readonly ["b", Decoder<bigint>]], which is the type inferred from the argument passed to structDecoder.decode(...).
Depending on how you write the mapped type StructTypeFor<Descriptor>, you get different types for struct.
Then, when we resolve the mapped type with Descriptor = readonly [readonly ["a", Decoder<number>], readonly ["b", Decoder<bigint>]], K will range over (keyof Descriptor) & number, but what does that intersection resolve to?
First, keyof Descriptor is going to be a union of:
"0", because Descriptor ends up being a tuple type of length 2
"1", also from the tuple type of length 2
"length", "toString", "map", "filter", "reduce", etc, all the array methods, because a tuple is an array after all, so those methods get inherited by tuple types
number, from the number index signature1 present in arrays, again because a tuple is an array (* this part doesn't entirely make sense to me... why do we need a fixed-length tuple type to have a number index signature?)
This union, when intersected with number, results in number, and that's what K ends up ranging over.
This results in the properties of our mapped type being Descriptor[number][0] = "a" | "b", and the types of those properties is ValueTypeOf<Descriptor[K][1]> = ValueTypeOf<Descriptor[number][1]> = ValueTypeOf<Decoder<number> | Decoder<bigint>> = number | bigint. (* this is an approximation that omits some details).
The result is that the type of struct resolves to { "a": number | bigint, "b": number | bigint }.
then roughly the same will happen, except K now will range over (keyof Descriptor) & `${number}` . keyof Descriptor resolves to the same union described above, i.e. "0" | "1" | "length" | "toString" | ... | number.
When that is intersected with `${number}`, the result is "0" | "1", and that's what K ranges over.
So the properties of the resolved mapped type will be Descriptor["0"][0] and Descriptor["1"][0], which are respectively "a" and "b".
When we resolve the type of a property of the mapped type, say the type of the property for when K is "0", we resolve ValueTypeOf<Descriptor[K][1]> = ValueTypeOf<Descriptor["0"][1]> = ValueTypeOf<Decoder<number>> = number.
The result is that the type of struct resolves to { "a": number, "b": bigint }.
then you get errors on Descriptor[K][0], saying that "Type '0' cannot be used to index type 'Descriptor[K]'.", and that "Type 'Descriptor[K][0]' is not assignable to type 'string | number | symbol'."
Note that we don't get the same error on Descriptor[K][1] occurring ValueTypeOf<Descriptor[K][1]>, because as of #48837, since K ranges over keyof Descriptor and Descriptor is an array or tuple type, K has an implicit constraint of number | `${number}` in ValueTypeOf<Descriptor[K][1]>.
So we can read that part of our mapped type declaration as ValueTypeOf<Descriptor[K & (number | `${number}`)][1]>.
Ignoring that error, then what happens is similar to to the first case, except that K will range over all properties of Descriptor now. When K is one of the array methods, then Descriptor[K][0] will resolve to unknown, and therefore those array methods don't contribute to the properties of the resolved type, so we're left with K ranging over "0" | "1" | number to produce the properties of the resolved type.
When we are resolving the types of the properties of the resolved mapped type, we resolve ValueTypeOf<Descriptor[K][1]>, and that ends up resolving to number | bigint via a similar process to the first case listed above.
Some things I don't understand or that bother me here:
A tuple type of a fixed, known length still has a number index signature, and that leads to us mapping over this index signature in a mapped type, and I find this surprising.
Adding an intersection to the K in keyof Descriptor with number and `${number}` has different behavior, and the distinction seems easy to overlook. Intersecting with `${number}` works because we represent the tuple properties as "0", "1", etc, i.e. as numeric string literals. But couldn't we also represent those properties as 0, 1, etc? It makes some sense to think so, because we do index arrays/tuples with numbers. I know ultimately, at run time, the numbers are converted to strings to access the array...
The other part of one way working while the other doesn't is that intersecting with `${number}` also gets rid of the number signature index, since number & `${number}` is never. But couldn't the index signature for arrays/tuples use `${number}` instead? I.e. couldn't it be [n: `${number}`]: SomeType?
When you write the mapped type with { [K in keyof Descriptor as Descriptor[K][0]]: ValueTypeOf<Descriptor[K][1]> }, you get an error on Descriptor[K][0] because we think K can range over any property of Descriptor, including the array methods. But you don't get a similar error on ValueTypeOf<Descriptor[K][1]>, because that occurrence of K is implicitly constrained to number | `${number}` since Improve support for numeric string typesΒ #48837. That distinction seems to me like an overlook, and I think there shouldn't be an error there. That distinction is fixed by part of Add numeric constraint to type parameter of mapped types with name type and array constraintsΒ #55774 (though that PR also implements other things).
A tuple type of a fixed, known length still has a number index signature, and that leads to us mapping over this index signature in a mapped type, and I find this surprising.
I suspect that not having that would result in some questionable DX:
consttuple=['',10]asconstfunctiongetX(i: number){returntuple[i]// would be an error}
For improved type safety of this access, you can opt into noUncheckedIndexedAccess. So with that option in mind, there is nothing quite wrong with having that number index. That option is not the default though.
Adding an intersection to the K in keyof Descriptor with number and ${number} has different behavior, and the distinction seems easy to overlook.
This βοΈ That's why I decided to open my PR - with it you don't even need to use an intersection so I think it's an improvement since one doesn't have to even consider what's the correct way to do this intersection. They can just use the builtin language features to achieve the desired outcome.
But couldn't we also represent those properties as 0, 1, etc?
That's (kinda) what I tried in #48599 and that PR ultimately led to #48837
I know ultimately, at run time, the numbers are converted to strings to access the array...
Yes, from that PoV TypeScript representation is correct. I don't think it's super pragmatic though :P but certainly, there are also other considerations here beyond just arrays. number/string indexers have at times weird behaviors/overlaps between each other.
But couldn't the index signature for arrays/tuples use ${number} instead? I.e. couldn't it be [n: ${number}]: SomeType?
It's worth noting that then arrays/tuples would have to have both index signatures because ${number} would reject plain numbers.
The current rule we have landed on is to instantiate mapped types with array and tuple types in a special way when the mapped type is homomorphic and has no as clause, i.e. mapped types that look like { [K in keyof T]: SomeType<T, K> }.
That means that, if you have such a mapped type instantiated with an array or tuple type:
then only the element properties of the type are considered when mapping (e.g. K will range over "0", "1", "2" in MappedTuple), so the "shape" (or the "array-ness", if you will) of the input type is preserved and the result produced is also an array or tuple type.
I think this rule makes it clear when the "array-ness" of the input type is preserved by a mapped type, and it does what users expect. Compare that to when a mapped type is homomorphic but has an as clause: TypeScript can't be sure whether or not you want this special behavior of preserving the "array-ness".
when instantiating mapped type StructTypeFor with some array type, because StructTypeFor is not a homomorphic mapped type without an as clause, K will range over all properties of type (keyof Descriptor) & number, as there will be no special instantiation behavior.
The same goes for rewriting the mapped type like this:
I agree with Gabriela Araujo Britto (@gabritto) 's reasoning - if this is solvable today, and the proposed fix would make things harder to understand, then the best thing to do is to stick with the current behavior. We can reevaluate in a new issue if unsolvable use cases appear.
Activity
[-]Can't remap tuple keys to object keys when using [/-][+]Can't remap tuple keys to object keys in mapped types[/+]whzx5byb commentedon Sep 16, 2023
May be related to #27995.
Workaround: Do not use
keyof SomeTuple & numberas a mapped index, instead, use a utility type to get the correct numeric index type of the tuple.type StructTypeFor<Descriptor extends StructDescriptor> = { - [K in (keyof Descriptor) & number as Descriptor[K][0]]: ValueTypeOf<Descriptor[K][1]>; + [K in KeyofTuple<Descriptor> as Descriptor[K][0]]: ValueTypeOf<Descriptor[K][1]>; };Andarist commentedon Sep 16, 2023
I think that perhaps this could work (but it doesn't):
I might play with implementing a change that would allow this if I find the time for this next week.
jfet97 commentedon Sep 17, 2023
You should use
& `${number}`instead of& number. That's because0,1, etc are string keys containing a number, not numbers.Playground
gabritto commentedon Oct 21, 2023
My own understanding of what is going on so far:
We have a mapped type called
StructTypeFor<Descriptor>. The return type ofstructDecoder.decode(...), i.e. the type ofstruct, is that mapped type instantiated withDescriptor = readonly [readonly ["a", Decoder<number>], readonly ["b", Decoder<bigint>]], which is the type inferred from the argument passed tostructDecoder.decode(...).Depending on how you write the mapped type
StructTypeFor<Descriptor>, you get different types forstruct.Then, when we resolve the mapped type with
Descriptor = readonly [readonly ["a", Decoder<number>], readonly ["b", Decoder<bigint>]],Kwill range over(keyof Descriptor) & number, but what does that intersection resolve to?First,
keyof Descriptoris going to be a union of:"0", becauseDescriptorends up being a tuple type of length 2"1", also from the tuple type of length 2"length","toString","map","filter","reduce", etc, all the array methods, because a tuple is an array after all, so those methods get inherited by tuple typesnumber, from thenumberindex signature1 present in arrays, again because a tuple is an array (* this part doesn't entirely make sense to me... why do we need a fixed-length tuple type to have anumberindex signature?)This union, when intersected with
number, results innumber, and that's whatKends up ranging over.This results in the properties of our mapped type being
Descriptor[number][0]="a" | "b", and the types of those properties isValueTypeOf<Descriptor[K][1]>=ValueTypeOf<Descriptor[number][1]>=ValueTypeOf<Decoder<number> | Decoder<bigint>>=number | bigint. (* this is an approximation that omits some details).The result is that the type of
structresolves to{ "a": number | bigint, "b": number | bigint }.then roughly the same will happen, except
Know will range over(keyof Descriptor) & `${number}`.keyof Descriptorresolves to the same union described above, i.e."0" | "1" | "length" | "toString" | ... | number.When that is intersected with
`${number}`, the result is"0" | "1", and that's whatKranges over.So the properties of the resolved mapped type will be
Descriptor["0"][0]andDescriptor["1"][0], which are respectively"a"and"b".When we resolve the type of a property of the mapped type, say the type of the property for when
Kis"0", we resolveValueTypeOf<Descriptor[K][1]>=ValueTypeOf<Descriptor["0"][1]>=ValueTypeOf<Decoder<number>>=number.The result is that the type of
structresolves to{ "a": number, "b": bigint }.then you get errors on
Descriptor[K][0], saying that "Type '0' cannot be used to index type 'Descriptor[K]'.", and that "Type 'Descriptor[K][0]' is not assignable to type 'string | number | symbol'."Note that we don't get the same error on
Descriptor[K][1]occurringValueTypeOf<Descriptor[K][1]>, because as of #48837, sinceKranges overkeyof DescriptorandDescriptoris an array or tuple type,Khas an implicit constraint ofnumber | `${number}`inValueTypeOf<Descriptor[K][1]>.So we can read that part of our mapped type declaration as
ValueTypeOf<Descriptor[K & (number | `${number}`)][1]>.Ignoring that error, then what happens is similar to to the first case, except that
Kwill range over all properties ofDescriptornow. WhenKis one of the array methods, thenDescriptor[K][0]will resolve tounknown, and therefore those array methods don't contribute to the properties of the resolved type, so we're left withKranging over"0" | "1" | numberto produce the properties of the resolved type.When we are resolving the types of the properties of the resolved mapped type, we resolve
ValueTypeOf<Descriptor[K][1]>, and that ends up resolving tonumber | bigintvia a similar process to the first case listed above.Some things I don't understand or that bother me here:
A tuple type of a fixed, known length still has a
numberindex signature, and that leads to us mapping over this index signature in a mapped type, and I find this surprising.Adding an intersection to the
K in keyof Descriptorwithnumberand`${number}`has different behavior, and the distinction seems easy to overlook. Intersecting with`${number}`works because we represent the tuple properties as"0","1", etc, i.e. as numeric string literals. But couldn't we also represent those properties as0,1, etc? It makes some sense to think so, because we do index arrays/tuples with numbers. I know ultimately, at run time, the numbers are converted to strings to access the array...The other part of one way working while the other doesn't is that intersecting with
`${number}`also gets rid of thenumbersignature index, sincenumber & `${number}`isnever. But couldn't the index signature for arrays/tuples use`${number}`instead? I.e. couldn't it be[n: `${number}`]: SomeType?When you write the mapped type with
{ [K in keyof Descriptor as Descriptor[K][0]]: ValueTypeOf<Descriptor[K][1]> }, you get an error onDescriptor[K][0]because we thinkKcan range over any property ofDescriptor, including the array methods. But you don't get a similar error onValueTypeOf<Descriptor[K][1]>, because that occurrence ofKis implicitly constrained tonumber | `${number}`since Improve support for numeric string typesΒ #48837. That distinction seems to me like an overlook, and I think there shouldn't be an error there. That distinction is fixed by part of Add numeric constraint to type parameter of mapped types with name type and array constraintsΒ #55774 (though that PR also implements other things).Footnotes
by "
numberindex signature" I mean here an index signature that looks like[n: number]: SomeType. β©Andarist commentedon Oct 21, 2023
I suspect that not having that would result in some questionable DX:
For improved type safety of this access, you can opt into
noUncheckedIndexedAccess. So with that option in mind, there is nothing quite wrong with having thatnumberindex. That option is not the default though.This βοΈ That's why I decided to open my PR - with it you don't even need to use an intersection so I think it's an improvement since one doesn't have to even consider what's the correct way to do this intersection. They can just use the builtin language features to achieve the desired outcome.
That's (kinda) what I tried in #48599 and that PR ultimately led to #48837
Yes, from that PoV TypeScript representation is correct. I don't think it's super pragmatic though :P but certainly, there are also other considerations here beyond just arrays. number/string indexers have at times weird behaviors/overlaps between each other.
It's worth noting that then arrays/tuples would have to have both index signatures because
${number}would reject plain numbers.gabritto commentedon May 1, 2024
I think for now we don't really want to change the rules regarding (homomorphic) mapped types instantiated with array or tuple types.
There's already a way to express what this issue asks for, as suggested above:
The current rule we have landed on is to instantiate mapped types with array and tuple types in a special way when the mapped type is homomorphic and has no
asclause, i.e. mapped types that look like{ [K in keyof T]: SomeType<T, K> }.That means that, if you have such a mapped type instantiated with an array or tuple type:
then only the element properties of the type are considered when mapping (e.g.
Kwill range over"0", "1", "2"inMappedTuple), so the "shape" (or the "array-ness", if you will) of the input type is preserved and the result produced is also an array or tuple type.I think this rule makes it clear when the "array-ness" of the input type is preserved by a mapped type, and it does what users expect. Compare that to when a mapped type is homomorphic but has an
asclause: TypeScript can't be sure whether or not you want this special behavior of preserving the "array-ness".In the issue's example:
when instantiating mapped type
StructTypeForwith some array type, becauseStructTypeForis not a homomorphic mapped type without anasclause,Kwill range over all properties of type(keyof Descriptor) & number, as there will be no special instantiation behavior.The same goes for rewriting the mapped type like this:
(see also #58237 (comment))
RyanCavanaugh commentedon May 1, 2024
I agree with Gabriela Araujo Britto (@gabritto) 's reasoning - if this is solvable today, and the proposed fix would make things harder to understand, then the best thing to do is to stick with the current behavior. We can reevaluate in a new issue if unsolvable use cases appear.
typescript-bot commentedon May 4, 2024
This issue has been marked as "Working as Intended" and has seen no recent activity. It has been automatically closed for house-keeping purposes.