TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

mapped type with remapped keys unexpectedly widens type

Open rotu opened this issue 1 year ago • 12 comments

🔎 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

Workbench Repro

🙁 Actual behavior

keyof R is string | number.

🙂 Expected behavior

keyof R is string.

Additional information about the issue

No response

rotu avatar Feb 01 '24 23:02 rotu

Related? #48837 / #55774

In a homomorphic mapped type { [K in keyof T]: XXX }, where T is constrained to an array or tuple type, K has an implied constraint of ``number | ${number}`.

Though Record<string, unknown> is not an array or tuple type.

rotu avatar Feb 01 '24 23:02 rotu

It changed in [email protected] so by https://github.com/microsoft/TypeScript/pull/45923 . We can see the code responsible for this here

Andarist avatar Feb 02 '24 08:02 Andarist

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).

Andarist avatar Feb 02 '24 09:02 Andarist

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).

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:

// keyof currently always returns string | number for concrete string index 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
//   ^?

Workbench Repro

rotu avatar Feb 02 '24 19:02 rotu

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)

fatcerberus avatar Feb 03 '24 06:02 fatcerberus

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)

That's one way to think of it. But I don't think it really makes sense.

  • keyof is most useful for object literals, and having keyof {1:null} be 1 | "1" would be quite ugly. It's nice that keyof is in direct correspondence with keys in object literals.
  • Numbers are not "special" for bracketing. Other primitives like null or true are just as valid as 1 as an object key, but we don't include them when the key type is string.
  • 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!
// @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)

rotu avatar Feb 04 '24 03:02 rotu

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.

fatcerberus avatar Feb 04 '24 18:02 fatcerberus

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

Workbench Repro

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).

rotu avatar Feb 04 '24 23:02 rotu

: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: -

  • Type 'string | number' is not assignable to type 'string'. Type 'number' is not assignable to type 'string'.

typescript-bot avatar Feb 15 '24 08:02 typescript-bot

: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 = string
  • type V2 = string
  • const k: string
  • const k: string
  • type K1 = string
  • type 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:

  • type V1 = string
  • type V2 = string
  • const k: string
  • const k: string
  • type K1 = string
  • type K2 = string | number

typescript-bot avatar Feb 15 '24 08:02 typescript-bot

: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: never
  • const p3: "string"
  • const p4: "numstring"
  • const p5: "number"
  • const p6: never
  • const p7: "string"
  • const p8: "number"
  • const p9: never
  • const 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:

  • const p1: "number"
  • const p2: never
  • const p3: "string"
  • const p4: "numstring"
  • const p5: "number"
  • const p6: never
  • const p7: "string"
  • const p8: "number"
  • const p9: never
  • const p10: "numstring"
  • const p11: "string"

typescript-bot avatar Feb 15 '24 08:02 typescript-bot

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

Playground

13OnTheCode avatar Apr 02 '24 09:04 13OnTheCode