TypeScript: Object.values and Object.entries are unsound and inconsistent with Object.keys.

Search Terms: Object.values Object.entries sound soundness unsound inconsistent Object.keys

Code Proposed change: https://github.com/MicahZoltu/TypeScript/commit/603c36370c7fdcd142b1493ca8cbbb955db73b69

Related Issues: #12207 #12253

Back in November 2016, a PR (#12207) was submitted to make the types of Object.entries and Object.values generic. This PR was reviewed and accepted, and as a note it was recommended that Object.keys be updated similarly. The author then submitted a PR to update Object.keys (#12253) but @ahejlsberg made the very valid point that this change was unsound and the PR was closed. The author of both PRs then suggested that perhaps #12207 should be reverted, but this revert never happened.

This issue is to discuss options for rectifying this situation and I propose we make Object.entries and Object.values consistent with the sound behavior of Object.keys. The major issue here is that this IS a breaking change since people may be relying on the currently unsound behavior of Object.values and Object.entries. However, I think having TypeScript be inconsistent on this front indefinitely is not good, and I don’t think changing Object.keys to be unsound is the right solution, so at the least this change should be merged into TS 4.x.

About this issue

  • Original URL
  • State: open
  • Created 4 years ago
  • Reactions: 45
  • Comments: 28 (4 by maintainers)

Most upvoted comments

I think if Typescript is about improving developer experience, Object.keys should return (keyof T)[], it’s easy enough to typecast it to make it be keyof T but, there should at least be something like Object.typeCastedKeys or something of that ilk.

I don’t know if you’ve seen it, but we basically get a bug report or PR to change Object.keys to keyof T at least once a week.

IMO that should be setting off huge alarm bells that the current typing of Object.keys is a problem. At a minimum as const should work for objects/records in a similar way as they do for arrays.

const arr = ['a', 'b'] as const
arr.map(x => console.log(x)) // x is correctly typed as: 'a' | 'b'

const obj = { foo: 'a', bar: 'b'} as const
Object.keys(obj).map(key => console.log(key)) // key is just string

+1 for moving Object.entries and Object.values to the current Object.keys behavior. Out of the two, it’s the more sound definition and prevents more unexpected type errors. It also aligns more with how Typescript’s type system works, and forces more people to become aware of object type compatibility.

Moving Object.keys to keyof T provides a false sense of safety, but also doesn’t align with how Typescript’s type system works. Typescript object types declare subsets of every object, not a strict definition of the object’s shape. It’s important for users to understand that any arbitrary object can be a member of an object type, so long as it has the specified fields, and that when iterating over the object’s keys or values, it’s possible for there to be an infinite number of keys in addition to the specified ones. Accessing properties on an object is safe, but iterating over object properties may produce an unexpected and unsafe result.

Typescript is not a language for improving your editor’s autocomplete, it’s a language for preventing type errors. Given how Typescript’s type system works, it’s safer to use an unknown values type and a string[] key type because those are correct. Again, any arbitrary object can be assigned to another type because of how type compatibility works. The alternative not only gives users a false sense of security, but also encourages misconceptions about how Typescript’s type system works.

It’s an unfortunate situation but I can’t imagine the upside to moving things in a direction that so many people would simply consider worse, even if it’s more correct. I don’t know if you’ve seen it, but we basically get a bug report or PR to change Object.keys to keyof T at least once a week.

IMO that should be setting off huge alarm bells that the current typing of Object.keys is a problem. At a minimum as const should work for objects/records in a similar way as they do for arrays.

That is still not type safe. TypeScript has no way to guarantee the fields you can iterate over when you’re iterating over all of an object’s keys. Const assertions prevent type widening, but it’s still possible to set arbitrary fields on them by asserting any or something like that. Convenience is not the same thing as type safety.

asserting any kills any type safety. it’s not limited to Object.keys. you can shoot yourself in foot with any in every possible way.

Yeah, I have seen many of the Object.keys reports and it is a common question in TS Discord, people asking why Object.keys isn’t generic. What bugs me the most at the moment is the inconsistency between Object.keys and Object.values/Object.entries. I would be willing to defer to the TS dev team’s judgement on whether to go with generics or not and I can submit a PR to “fix” either.

Personally, I think that the sound solution is preferable, but I understand that often the TS dev team makes soundness sacrifices for usability reasons. The main reason I didn’t lead with a PR to “fix” Object.keys is because my searching suggests that many have tried this in the past and it is consistently rejected, which is why I thought perhaps going the other direction would get more traction.

Aha, I found it finally! I’m having hard enough time justifying it to myself and explaining to my team mates why Object.keys returns string[]. And once I manage that, I have zero explanation why the same doesn’t apply to Object.values. Even though the same code example that demonstrates soundness of Object.keys demonstrates the unsoundness of Object.values (or vice versa - depending on your view point).

I personally do not agree the behavior of Object.keys is sound - after all there are quite a lot of other ways to break static type guarantees (@susisu just demonstrated one of them in the comment above). But no matter what, I believe consistency is even more important.

If adding an extra package (maybe already been installed) to your prject is not a problem, you can have a solution with rambda toPairs:

import { toPairs } from "rambda"

toPairs({a: 1, b: 2, c: 3}); //=> [['a', 1], ['b', 2], ['c', 3]]
Screenshot 2023-06-07 at 10 05 08

All Keys types are have been stored!

Docs:

https://ramdajs.com/docs/#toPairs

https://github.com/ramda/ramda/blob/v0.29.0/source/toPairsIn.js

I wrote out this appeal just over a year ago, which unfortunately was closed: https://github.com/microsoft/TypeScript/issues/45835

I feel that we should still consider it, the crux being:

Within a structural type-system and in the absence of Exact Types, it would appear that most Object reflection methods must be typed imprecisely wide, including Object.entries and Object.values.

But this kind of feels like it violates Typescripts third non-goal: Precise types for object methods are incredibly useful, which is clear by the number of requests, questions and PRs around Object.keys type signature.

Given the state of things it feels like one of two things should happen:

  1. Remove these two type signatures, and revisit when Exact Types land.
  2. Add a more precise Object.keys type definition override, and maintain at least the same level of unsoundness that already exists.

Sample Code

Something like this maintains parity with the status-quo:

interface ObjectConstructor {
    keys<T extends Record<string, unknown>>(o: T): (keyof T)[];
}

Which is not a valid program in the SO example, but demonstrates the same level of unsoundness that already exists

The first part is just a bit of context for why I think one of the two above things should happen
`@jcalz` has enumerated a non-exhaustive list [here](https://github.com/microsoft/TypeScript/issues/45390#issuecomment-895661910), the issue template links to the [SO Answer](https://stackoverflow.com/questions/55012174/why-doesnt-object-keys-return-a-keyof-type-in-typescript). I'm aware of these resources, I know that this comes up quite frequently, but it would be greatly appreciated if you would hear me out :pray:

It’s instructive to go way back to 2016 with this comment in a follow-up PR to #12207 – which will be relevant in a few seconds.

Once you move to the instantiated type world it degenerates because an object can (and often does) have more properties at run-time than are statically known at compile time.

@RyanCavanaugh sort of codifies these hesitations in the SO answer. The crux of this argument is that a more-precisely typed Object.keys is potentially unsound. We get a first hand demonstration of this by observing some well-typed program that would produce runtime errors.

But TS is already in this state where very similar code, is A) well-typed, but B) will produce runtime errors. This comes down to how Object.values and Object.entries are typed.

See two examples here, using Object.values:

Note: this was known way back in 2016, where @ahejlsberg called this out in the same comment:

BTW, I have the same reservations about Object.entries which I notice is now typed this way.

@amakhrov it’s not just about literals. The same problem occurs any time you have a subtype with additional properties (e.g. here’s an example with classes).

But you’re right that the lack of exactness is implicated here—TypeScript types only ever describe a subset of the properties which will exist at runtime, so the callback given to Object.keys receives arguments that may be wider than keyof T. I’m hopeful that if the JavaScript “Record & Tuple” proposal is implemented it’ll provide some avenue for handling exactness, but time will tell.

@ahejlsberg said

For example, imagine a type Base with just a few properties and a family of types derived from that. Calling Object.keys with a Base would return a (keyof Base)[] which is almost certainly wrong because an actual instance would be a derived object with more keys. It completely degenerates for type {} which could be any object but would return never[] for its keys.

what about allow something like (keyof Base | string)[]? so everyone is happy (safety and autocomplete) (maybe typescript keeping some information about unions)

Any solutions provided so far?

@probablykasper This example only shows that Record is unsound, not “object keys and values” in general. I assume that Record being unsound is a design decision, because if you think about it there should be no way to make any element of type Record<string, string> (except with black magic like proxies). It has in any case nothing to do with Object.keys and Object.values.

An even more minimal example is

type X = {a: unknown}

const f = (x: X) => Object.keys(x) // should expect ['a']

f({a: 1, b: 2}) // but you actually get `['a', 'b']`

This reminds me of variance issues with generics as well

@devinrhode2

@mdbetancourt I like the effort, but, try putting that into typescript playground:

type myKeys = keyof Base | string

string is wider than keyof Base, and will “swallow” the other type, reducing it to just string.

yup i know 😃 that’s the part of what about allow and maybe typescript keeping some information about unions instead of “swallow” the other type.

related to: https://github.com/microsoft/TypeScript/issues/46859 and https://github.com/microsoft/TypeScript/issues/46548

Honestly, I don’t understand this Object.keys situation. I don’t recall seeing one good example of how typescript will mislead developers by using keyof T.

I do have an npm package which gives you a well-typed alias. I have a stack overflow answer that details an npm package I created, object-typed: https://stackoverflow.com/a/65117465/565877

If we are concerned about actual runtime type checking, TS team should develop a tool to actually check runtime types (during development only)