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

Incorrectly infers Object key to be number instead of string #45170

Closed
leebyron opened this issue Jul 23, 2021 · 3 comments
Closed

Incorrectly infers Object key to be number instead of string #45170

leebyron opened this issue Jul 23, 2021 · 3 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@leebyron
Copy link

leebyron commented Jul 23, 2021

Bug Report

In all recent versions of Typescript, TS may infer an object property to be a subtype of number instead of a subtype of string if that property is written in the quote-less shorthand and happens to be a numeric string (eg { 1: "One" } instead of { "1": "One" } (these two objects being equivalent, since all non Array-exotic objects have string named literal properties and all property keys must be symbols or strings). This affects both the built in keyof and types with index signatures.

🔎 Search Terms

Numeric string index signatures, object keys

🕗 Version & Regression Information

Has always been an issue

⏯ Playground Link

Playground Link showing keyof

Playground Link showing runtime behavior not mapping to type inference

💻 Code

Smallest example using keyof

const obj = {1: 'One'}

// Incorrectly infers type 1 instead of "1"
type X = keyof typeof obj

Example showing function runtime behavior differing from inference:

// Converts a plain object to a Map, using all properties as Map keys.
function objectToMap<K extends string | number | symbol, V>(obj: { [P in K]?: V }): ReadonlyMap<K, V> {
    return new Map(Object.entries(obj) as any)
}

// TS infers this to be ReadonlyMap<'a' | 1, string> when it should be ReadonlyMap<'a' | '1', string>
const result = objectToMap({ a: 'Ah', 1: 'One' })

// TS infers this to be valid, and type string | undefined, when in fact it is always undefined.
console.log(1, result.get(1))

// TS infers this an an error, when in fact it is a valid key and returns a string value ("One").
console.log('1', result.get('1')

🙁 Actual behavior

TS infers non Array-exotic object numeric string keys are numbers

🙂 Expected behavior

TS should infer that all object keys are strings, even numeric strings

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Jul 23, 2021
@RyanCavanaugh
Copy link
Member

This is the intended behavior. The meaning of keyof isn't "What runtime storage representation is there", it's "What kind of values would be legal to index this object with", and it's intended that you can write

declare function read<T, K extends keyof T>(a: T, k: K): T[K];
const arr = [0, 1, 2];
// Proposed: This should be made illegal, because 0 is not a string
read(arr, 0);

instead of

read(arr, "0");

because we equally let you write

const e= arr[0];

@typescript-bot
Copy link
Collaborator

This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@leebyron
Copy link
Author

leebyron commented Sep 1, 2021

For anyone coming back across this via search, here's some resolution I found for this issue.

"What runtime storage representation is there", it's "What kind of values would be legal to index this object with"

This is pretty subtle. Technically the values legal to index the object are the storage representation, but JS has a quirk where it first converts non-symbols to strings via the indexing syntax. Other methods of accessing object properties don't do this, so I suppose this will always a be a footgun and not one TypeScript intends to save people from.

// Proposed: This should be made illegal, because 0 is not a string
read(arr, 0);

I'd propose that Array exotics should keep the behavior where keyof returns numbers and index types should definitely support restricting to numbers. Non-exotics however are where most JS novices get tripped up - numeric string shorthand leads to misconception of storing actual numbers as the property name, and seeing Object.keys() return strings is a common confusion. The keyof using a different behavior reenforces this confusion. (I'm coming at this via Immutable.js types, we see this as a "bug report" quite a lot)


For those looking to resolve this quirk locally, here's a small utility for getting the actual safe-to-access property keys from an object via the fantastic new template string types syntax:

type PropOf<T> = { [K in keyof T]: K extends number ? `${K}` : K }[keyof T]

const obj = {1: 'One', '2': 'Two'}

// Incorrectly infers type 1|"2" instead of "1"|"2"
type KeysAsWritten = keyof typeof obj

// Correctly infers type "1"|"2"
type KeysAsStored = PropOf<typeof obj>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

3 participants