镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

mapped type with remapped keys unexpectedly widens type #57265

Description

@rotu

🔎 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

Activity

  1. rotu commented on Feb 1, 2024

    @rotu
    Author

    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.

  2. Andarist commented on Feb 2, 2024

    @Andarist
    Contributor

    It changed in typescript@4.5.0-dev.20210928 so by #45923 . We can see the code responsible for this here

  3. Andarist commented on Feb 2, 2024

    @Andarist
    Contributor

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

  4. rotu commented on Feb 2, 2024

    @rotu
    Author

    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

  5. fatcerberus commented on Feb 3, 2024

    @fatcerberus

    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)

  6. rotu commented on Feb 4, 2024

    @rotu
    Author

    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)

  7. fatcerberus commented on Feb 4, 2024

    @fatcerberus

    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.

  8. rotu commented on Feb 4, 2024

    @rotu
    Author

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

  9. typescript-bot commented on Feb 15, 2024

    @typescript-bot
    Contributor

    👋 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 Dan Rose (@rotu)

    ‼️ Exception: Error - error TS5107: Option 'moduleResolution=node10' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error. Visit https://aka.ms/ts6 for migration information.

    Error: error TS5107: Option 'moduleResolution=node10' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error.
      Visit https://aka.ms/ts6 for migration information.
    
        at Object.createVirtualTypeScriptEnvironment (/home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:8128:11)
        at twoslasher (/home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:7618:17)
        at /home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:439:44
        at runTwoslashRequests (/home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:406:56)
        at run (/home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:20096:75)
        at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    

    Historical Information
    Version Reproduction Outputs Time
    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'.

    ⚠️ Way slower
  10. typescript-bot commented on Feb 15, 2024

    @typescript-bot
    Contributor

    👋 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 Dan Rose (@rotu)

    ‼️ Exception: Error - error TS5107: Option 'moduleResolution=node10' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error. Visit https://aka.ms/ts6 for migration information.

    Error: error TS5107: Option 'moduleResolution=node10' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error.
      Visit https://aka.ms/ts6 for migration information.
    
        at Object.createVirtualTypeScriptEnvironment (/home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:8128:11)
        at twoslasher (/home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:7618:17)
        at /home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:439:44
        at runTwoslashRequests (/home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:406:56)
        at run (/home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:20096:75)
        at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    

    Historical Information
    Version Reproduction Outputs Time
    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

    ⚠️ Way slower
  11. typescript-bot commented on Feb 15, 2024

    @typescript-bot
    Contributor

    👋 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 Dan Rose (@rotu)

    ‼️ Exception: Error - error TS5107: Option 'moduleResolution=node10' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error. Visit https://aka.ms/ts6 for migration information.

    Error: error TS5107: Option 'moduleResolution=node10' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error.
      Visit https://aka.ms/ts6 for migration information.
    
        at Object.createVirtualTypeScriptEnvironment (/home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:8128:11)
        at twoslasher (/home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:7618:17)
        at /home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:439:44
        at runTwoslashRequests (/home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:406:56)
        at run (/home/runner/work/_actions/microsoft/TypeScript-Twoslash-Repro-Action/master/dist/index.js:20096:75)
        at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    

    Historical Information
    Version Reproduction Outputs Time
    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"

    ⚠️ Way slower
  12. 13OnTheCode commented on Apr 2, 2024

    @13OnTheCode

    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

Metadata

Metadata

Labels

Has ReproThis issue has compiler-backed repros: https://aka.ms/ts-reprosNeeds InvestigationThis issue needs a team member to investigate its status.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions