Skip to content
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

mapped type with remapped keys unexpectedly widens type #57265

Open
rotu opened this issue Feb 1, 2024 · 12 comments
Open

mapped type with remapped keys unexpectedly widens type #57265

rotu opened this issue Feb 1, 2024 · 12 comments
Assignees
Labels
Has Repro This issue has compiler-backed repros: https://aka.ms/ts-repros Needs Investigation This issue needs a team member to investigate its status.

Comments

@rotu
Copy link

rotu commented Feb 1, 2024

πŸ”Ž 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
Copy link
Author

rotu commented Feb 1, 2024

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.

@Andarist
Copy link
Contributor

Andarist commented Feb 2, 2024

It changed in [email protected] so by #45923 . We can see the code responsible for this here

@Andarist
Copy link
Contributor

Andarist commented Feb 2, 2024

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

@rotu
Copy link
Author

rotu commented Feb 2, 2024

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

@RyanCavanaugh RyanCavanaugh added the Needs Investigation This issue needs a team member to investigate its status. label Feb 2, 2024
@fatcerberus
Copy link

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)

@rotu
Copy link
Author

rotu commented Feb 4, 2024

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)

@typescript-bot typescript-bot added the Has Repro This issue has compiler-backed repros: https://aka.ms/ts-repros label Feb 4, 2024
@fatcerberus
Copy link

fatcerberus commented Feb 4, 2024

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.

@rotu
Copy link
Author

rotu commented Feb 4, 2024

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

@typescript-bot
Copy link
Collaborator

typescript-bot commented Feb 15, 2024

πŸ‘‹ 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

❌ 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

❌ Failed: -

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

@typescript-bot
Copy link
Collaborator

typescript-bot commented Feb 15, 2024

πŸ‘‹ 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

⚠️ 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

⚠️ Assertions:

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

@typescript-bot
Copy link
Collaborator

typescript-bot commented Feb 15, 2024

πŸ‘‹ 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

⚠️ 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

⚠️ 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"

@13OnTheCode
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Has Repro This issue has compiler-backed repros: https://aka.ms/ts-repros Needs Investigation This issue needs a team member to investigate its status.
Projects
None yet
Development

No branches or pull requests

7 participants