mapped type with remapped keys unexpectedly widens type
🔎 Search Terms
keyof, string, record
🕗 Version & Regression Information
- This changed between versions 4.4.4 and 4.5.5
⏯ Playground Link
No response
💻 Code
type R = { [K in keyof Record<string,unknown> as K]: unknown; };
// ^?
type K = keyof R;
// ^?
let s:string = "a" as K
🙁 Actual behavior
keyof R is string | number.
🙂 Expected behavior
keyof R is string.
Additional information about the issue
No response
Related? #48837 / #55774
In a homomorphic mapped type
{ [K in keyof T]: XXX }, whereTis constrained to an array or tuple type,Khas an implied constraint of ``number | ${number}`.
Though Record<string, unknown> is not an array or tuple type.
It changed in [email protected] so by https://github.com/microsoft/TypeScript/pull/45923 . We can see the code responsible for this here
The UX problem that we can recognize here is that all of those 3 display in the same way ({ [k: string]: unknown; }):
type A = { [k: string]: unknown }
type B = Record<string, unknown>
type C = { [K in keyof B as K]: unknown }
However, they are not functionally identical and that's the surprising behavior. keyof B (a mapped type without the nameType) just gets the constraintType of the mapped type (so string here) and returns that as-is but C (a renaming mapped type) behaves like A (even though it's still a mapped type internally, it's not like it's eagerly resolved to a plain object type).
The UX problem that we can recognize here is that all of those 3 display in the same way (
{ [k: string]: unknown; }):type A = { [k: string]: unknown } type B = Record<string, unknown> type C = { [K in keyof B as K]: unknown }However, they are not functionally identical and that's the surprising behavior.
keyof B(a mapped type without thenameType) just gets theconstraintTypeof the mapped type (sostringhere) and returns that as-is butC(a renaming mapped type) behaves likeA(even though it's still a mapped type internally, it's not like it's eagerly resolved to a plain object type).
Yes there is a UX problem here that two different types are presented the same and behave differently, and that's definitely a problem.
It's still a mystery to me is WHY the key gets widened in the first place from string to string | number. This was clearly intentional, as mentioned in the 2.9 release notes and the PR you linked:
//
keyofcurrently always returnsstring | numberfor concretestringindex signatures - the below ternary keeps that behavior for mapped types
The keys of an object are never numbers - they're always strings. Property access by number coerces the number to a string, both at the type level and at the value level. Why does keyof perform the reverse widening instead of just letting the forward coercion deal with this when accessing the property?
I think the discussion at #41966 bears on this, and I wonder if #43041 (An alternative option to keyofStringsOnly that stringifies numeric properties and index signatures) can be prioritized, given that keyofStringsOnly is now deprecated.
Here's my trying to understand:
// @target: ES2022
const r = {0:'zero', 1:'one', 2:'two'}
// two similar record types
type R1 = Record<string, string>
type R2 = {[s:string]:string}
// both types are indexable by number
const v1 = (r as R1)[0]
const v2 = (r as R2)[0]
// and have values indexable by number
type V1 = R1['0']
// ^?
type V2 = R2['0']
// ^?
// and for-in types the iterated keys to be strings
for (const k in (r as R1)){}
// ^?
for (const k in (r as R2)){}
// ^?
// but `keyof` differs between the types:
type K1 = keyof R1
// ^?
type K2 = keyof R2
// ^?
The keys of an object are never numbers - they're always strings. Property access by number coerces the number to a string, both at the type level and at the value level. Why does keyof perform the reverse widening instead of just letting the forward coercion deal with this when accessing the property?
This is true, but note they're not completely unified at the type level - types can have separate string and number index signatures, the former is just required to subsume the latter if both exist on the same type (for obvious reasons). keyof X just tells you what kind of value you're allowed to put in between the brackets, for which the most accurate answer is string | number). Note that this actually makes Record<string, T> the odd one out - as it only has string for its keyof - but I believe that was done to preserve the natural contravariance of the key type)
This is true, but note they're not completely unified at the type level - types can have separate
stringandnumberindex signatures, the former is just required to subsume the latter if both exist on the same type (for obvious reasons).keyof Xjust tells you what kind of value you're allowed to put in between the brackets, for which the most accurate answer isstring | number)
That's one way to think of it. But I don't think it really makes sense.
keyofis most useful for object literals, and havingkeyof {1:null}be1 | "1"would be quite ugly. It's nice thatkeyofis in direct correspondence with keys in object literals.- Numbers are not "special" for bracketing. Other primitives like
nullortrueare just as valid as1as an object key, but we don't include them when the key type isstring. - The
stringandnumberindex signatures don't have to be in correspondence - only if they're declared in an object literal (which is. For funsies I made a whole set of pathological examples!
// @target:ES2022
declare const o : {[x:number]:'number'}&{[x:string]:'string'}&{[x:`${number}`]:'numstring'}
const p1 = o[1]
// ^?
const p2 = o['1']
// ^?
const p3 = o['one']
// ^?
const p4 = o['0x0']
// ^?
const p5 = o['Infinity']
// ^?
const p6 = o[`${300n}`]
// ^?
const p7 = o['123_456']
// ^?
const p8 = o[123_456]
// ^?
const p9 = o['-1']
// ^?
const p10 = o['+1']
// ^?
const p11 = o[`${[6]}`]
// ^?
Workbench Repro (fixed link)
The string and number index signatures don't have to be in correspondence - only if they're declared in an object literal (which is. For funsies I made a whole set of pathological examples!
Fun little technicality there: Those pathological cases actually only exist because of a design limitation. The type { [x: number]: 'number', [x: string]: 'string' } is not actually a legal type but for one little quirk: type instantiation (including the construction of an intersection type) is not allowed to fail. As a result, you can construct such types via intersection, but they're generally not well-behaved. It's for the same reason that you can write things like { [x: string]: string } & { foo: number } --which are also not well-behaved; see e.g. discussion at #17867.
Fun little technicality there: Those pathological cases actually only exist because of a design limitation. The type
{ [x: number]: 'number', [x: string]: 'string' }is not actually a legal type but for one little quirk: type instantiation (including the construction of an intersection type) is not allowed to fail.
The inconsistency does not require use of intersection types to see:
declare const o : {
[x:string]:'string'|'number'|'numstring',
[x:number]:'number'|'numstring',
[x:`${number}`]:'numstring'
}
// rest stay the same
And that design limitation does not excuse/explain the behavior. TypeScript could treat the type as never as it does for {x:1} & {x:'one'} or infer the property to be never as it does for 3 of those 11 cases (no spoilers).
:wave: Hi, I'm the Repro bot. I can help narrow down and track compiler bugs across releases! This comment reflects the current state of the repro in the issue body running against the nightly TypeScript.
Issue body code block by @rotu
:x: Failed: -
Type 'string | number' is not assignable to type 'string'. Type 'number' is not assignable to type 'string'.
Historical Information
| Version | Reproduction Outputs |
|---|---|
| 4.9.3, 5.0.2, 5.1.3, 5.2.2, 5.3.2 |
:x: Failed: -
|
:wave: Hi, I'm the Repro bot. I can help narrow down and track compiler bugs across releases! This comment reflects the current state of this repro running against the nightly TypeScript.
Comment by @rotu
:warning: Assertions:
type V1 = stringtype V2 = stringconst k: stringconst k: stringtype K1 = stringtype K2 = string | number
Historical Information
| Version | Reproduction Outputs |
|---|---|
| 4.9.3, 5.0.2, 5.1.3, 5.2.2, 5.3.2 |
:warning: Assertions:
|
:wave: Hi, I'm the Repro bot. I can help narrow down and track compiler bugs across releases! This comment reflects the current state of this repro running against the nightly TypeScript.
Comment by @rotu
:warning: Assertions:
const p1: "number"const p2: neverconst p3: "string"const p4: "numstring"const p5: "number"const p6: neverconst p7: "string"const p8: "number"const p9: neverconst p10: "numstring"const p11: "string"
Historical Information
| Version | Reproduction Outputs |
|---|---|
| 4.9.3, 5.0.2, 5.1.3, 5.2.2, 5.3.2 |
:warning: Assertions:
|
I am currently solving this problem temporarily by defining a Keys<T> type tool.
type Keys<T> = { [P in keyof T as any]: P }[keyof T]
type R1 = Keys<Record<string,unknown>> // string
type R2 = Keys<{ [x: string]: unknown }> // string