193

Title says it all - why doesn't Object.keys(x) in TypeScript return the type Array<keyof typeof x>? That's what Object.keys does, so it seems like an obvious oversight on the part of the TypeScript definition file authors to not make the return type simply be keyof T.

Should I log a bug on their GitHub repo, or just go ahead and send a PR to fix it for them?

Ryan Cavanaugh
  • 209,514
  • 56
  • 272
  • 235
  • 2
    I opened and closed a PR today related to this topic. My PR was only focusing on the case where keys are coming from an enum of strings. In this precise case, it does not seem that inheritance is feasible. I need to double check before reopening it https://github.com/Microsoft/TypeScript/pull/30228 – DubZzz Mar 05 '19 at 23:00
  • FTR: That ^ PR was never merged – Devin Rhode Dec 04 '20 at 22:58
  • This question/answer has some good info that is relevant: https://stackoverflow.com/q/70420283/990642 – josephdpurcell Aug 19 '22 at 15:49

6 Answers6

218

The current return type (string[]) is intentional. Why?

Consider some type like this:

interface Point {
    x: number;
    y: number;
}

You write some code like this:

function fn(k: keyof Point) {
    if (k === "x") {
        console.log("X axis");
    } else if (k === "y") {
        console.log("Y axis");
    } else {
        throw new Error("This is impossible");
    }
}

Let's ask a question:

In a well-typed program, can a legal call to fn hit the error case?

The desired answer is, of course, "No". But what does this have to do with Object.keys?

Now consider this other code:

interface NamedPoint extends Point {
    name: string;
}

const origin: NamedPoint = { name: "origin", x: 0, y: 0 };

Note that according to TypeScript's type system, all NamedPoints are valid Points.

Now let's write a little more code:

function doSomething(pt: Point) {
    for (const k of Object.keys(pt)) {
        // A valid call if Object.keys(pt) returns (keyof Point)[]
        fn(k);
    }
}
// Throws an exception
doSomething(origin);

Our well-typed program just threw an exception!

Something went wrong here! By returning keyof T from Object.keys, we've violated the assumption that keyof T forms an exhaustive list, because having a reference to an object doesn't mean that the type of the reference isn't a supertype of the type of the value.

Basically, (at least) one of the following four things can't be true:

  1. keyof T is an exhaustive list of the keys of T
  2. A type with additional properties is always a subtype of its base type
  3. It is legal to alias a subtype value by a supertype reference
  4. Object.keys returns keyof T

Throwing away point 1 makes keyof nearly useless, because it implies that keyof Point might be some value that isn't "x" or "y".

Throwing away point 2 completely destroys TypeScript's type system. Not an option.

Throwing away point 3 also completely destroys TypeScript's type system.

Throwing away point 4 is fine and makes you, the programmer, think about whether or not the object you're dealing with is possibly an alias for a subtype of the thing you think you have.

The "missing feature" to make this legal but not contradictory is Exact Types, which would allow you to declare a new kind of type that wasn't subject to point #2. If this feature existed, it would presumably be possible to make Object.keys return keyof T only for Ts which were declared as exact.


Addendum: Surely generics, though?

Commentors have implied that Object.keys could safely return keyof T if the argument was a generic value. This is still wrong. Consider:

class Holder<T> {
    value: T;
    constructor(arg: T) {
        this.value = arg;
    }

    getKeys(): (keyof T)[] {
        // Proposed: This should be OK
        return Object.keys(this.value);
    }
}
const MyPoint = { name: "origin", x: 0, y: 0 };
const h = new Holder<{ x: number, y: number }>(MyPoint);
// Value 'name' inhabits variable of type 'x' | 'y'
const v: "x" | "y" = (h.getKeys())[0];

or this example, which doesn't even need any explicit type arguments:

function getKey<T>(x: T, y: T): keyof T {
    // Proposed: This should be OK
    return Object.keys(x)[0];
}
const obj1 = { name: "", x: 0, y: 0 };
const obj2 = { x: 0, y: 0 };
// Value "name" inhabits variable with type "x" | "y"
const s: "x" | "y" = getKey(obj1, obj2);
Dan Barclay
  • 5,827
  • 2
  • 19
  • 22
Ryan Cavanaugh
  • 209,514
  • 56
  • 272
  • 235
  • 22
    However, there is common case when point 3. is excluded, when for example `T` is inferred and is guaranteed to be precise: `const f: (t: T) => void = (t) => { Object.keys(t).forEach(k => t[k]) }`. I have lots of places like that in my code, where I really want `Object.keys()` to return (keyof T)[]. – artem Mar 05 '19 at 22:02
  • 13
    As arthem also points out, the confusion comes from the fact that 9 out of 10 times you will end up in some way using a type assertion to `keyof T` to do anything useful with the result of `keys`. You might argue it is better to be explicit about it so you are more aware of the risk you are taking, but probably 9/10 devs will just add the type assertion and not be aware of the issues you highlight .. – Titian Cernicova-Dragomir Mar 06 '19 at 00:16
  • Relevant GitHub issue/comment: https://github.com/Microsoft/TypeScript/pull/12253#issuecomment-263132208 – jcalz Mar 12 '19 at 13:36
  • 3
    Why can't `Object.keys(c extends T obj)` simply filter the keys on obj (type c) returning keys of T? – Loveen Dyall Mar 31 '20 at 15:34
  • Edited to address "What about generics though" comments. – Ryan Cavanaugh May 08 '20 at 22:59
  • I tried your first example, and typescript successfully caught the error, see [playground](tinyurl.com/yyyx9wy4) (playground url too long to paste into comment) – ZYinMD Sep 08 '20 at 13:09
  • 3
    Uhhm, so apart from the great theory of TypeScript and typed systems, it is impossible to use `Object.keys` with objects, which represent an interface? We are forced to create pointless helper functions, which replace the infrastructure of our language? – BairDev Dec 02 '20 at 14:03
  • 2
    I think this example is good but not very satisfying. – Devin Rhode Dec 02 '20 at 22:09
  • 6
    If anyone has actually been screwed up by type-casting Object.keys(foo) to Array, where the runtime value from Object.keys actually included more keys than were know at compile-time, Many people would love to see this code as an example. Please share it – Devin Rhode Dec 02 '20 at 22:10
  • 6
    Often, this is solely used to be able to loop through an object but that makes it impossible since ```Object.keys(product).forEach((key) => { // do something with product[key] but leads to error 'has type any because string cannot be used to query ...' });``` will lead to an error. – jperl Dec 09 '20 at 12:34
  • @jperl Exactly same issue here – partizanos Apr 22 '21 at 09:15
  • I fixed my type issue with this method: https://stackoverflow.com/questions/56568423/typescript-no-index-signature-with-a-parameter-of-type-string-was-found-on-ty – partizanos Apr 22 '21 at 10:56
  • 1
    Just to be clear, artem's example doesn't *guarantee* that `T` is exact, since the type parameter is still only inferred from the argument's compile-time type, which could be weaker than the object's runtime type. – kaya3 May 08 '21 at 22:02
  • Point 2 in your list: "A type with additional properties is always a subtype of its base type", is obviously the correct way to do it. If you ask for which members/properties a class has, you should obviously only get the properties which are defined for that class. And it's not a very good argument against point 2, to say that you uses an internal type system in TypeScript which doesn't support the strongly-typed superset of JavaScript which TypeScript claim to be. – NoOneSpecial Jul 25 '21 at 22:58
  • Is it possible TypeScript 4.9 could change this with [the satisfies operator](https://devblogs.microsoft.com/typescript/announcing-typescript-4-9-beta/#the-satisfies-operator)? It seems like it's a way of guaranteeing an object isn't a superset of an Interface, meaning all Object.keys WILL be keyof the interface. e.g., `const origin = { name: "origin", x: 0, y: 0 } satifies NamedPoint` will refuse to compile, to use your example. Thoughts? – Nathan Oct 07 '22 at 08:36
  • `satisfies` doesn't do anything to prevent aliasing with a supertype alias, which is the problem. You would need exact types to solve this "problem" – Ryan Cavanaugh Oct 07 '22 at 17:20
  • I'm always upset when @RyanCavanaugh comments on a subject I'm interested in, because it usually means I can't get the feature I want, and he always has a good reason. Good work on being the guardian of typescript that we need (but don't deserve) – Alfred Nov 10 '22 at 11:45
  • We really need exact types. – Woww Nov 19 '22 at 07:52
  • But why the type of `Object.keys({'a': 1, 'b': 2})` is `string[]` and not `'a'|'b'`? – gamliela Apr 27 '23 at 05:25
  • Because there isn't hardcoded special logic to handle that case (which you would never write in the first place) – Ryan Cavanaugh Apr 27 '23 at 18:42
  • What about for a plain object, not an interface? If I do `const myobj = {"foo": "bar"}`, typescript won't let me modify the keys. I can't figure out a way to extend/modify this in a way that if I did `Object.keys(myobj)` the keys wouldn't be `'foo'` – Kayson Aug 27 '23 at 03:03
42

For a workaround in cases when you're confident that there aren't extra properties in the object you're working with, you can do this:

const obj = {a: 1, b: 2}
const objKeys = Object.keys(obj) as Array<keyof typeof obj>
// objKeys has type ("a" | "b")[]

You can extract this to a function if you like:

const getKeys = <T>(obj: T) => Object.keys(obj) as Array<keyof T>

const obj = {a: 1, b: 2}
const objKeys = getKeys(obj)
// objKeys has type ("a" | "b")[]

As a bonus, here's Object.entries, pulled from a GitHub issue with context on why this isn't the default:

type Entries<T> = {
  [K in keyof T]: [K, T[K]]
}[keyof T][]

function entries<T>(obj: T): Entries<T> {
  return Object.entries(obj) as any;
}
rattray
  • 5,174
  • 1
  • 33
  • 27
12

This is the top hit on google for this type of issue, so I wanted to share some help on moving forwards.

These methods were largely pulled from the long discussions on various issue pages which you can find links to in other answers/comment sections.

So, say you had some code like this:

const obj = {};
Object.keys(obj).forEach((key) => {
  obj[key]; // blatantly safe code that errors
});

Here are a few ways to move forwards:

  1. If you don't need the keys and really just need the values, use .entries() or .values() instead of iterating over the keys.

    const obj = {};
    Object.values(obj).forEach(value => value);
    Object.entries(obj).forEach([key, value] => value);
    
  2. Create a helper function:

    function keysOf<T extends Object>(obj: T): Array<keyof T> {
      return Array.from(Object.keys(obj)) as any;
    }
    
    const obj = { a: 1; b: 2 };
    keysOf(obj).forEach((key) => obj[key]); // type of key is "a" | "b"
    
  3. Re-cast your type (this one helps a lot for not having to rewrite much code)

    const obj = {};
    Object.keys(obj).forEach((_key) => {
      const key = _key as keyof typeof obj;
      obj[key];
    });
    

Which one of these is the most painless is largely up to your own project.

Seph Reed
  • 8,797
  • 11
  • 60
  • 125
  • 6
    I recently banged my head against this and would like to toss one more option on to the heap: convert over to a [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map). It's a huge hassle to convert over to Map for supporting old code, but if you're writing something new, it's pretty easy to use it like the `Object.keys` pattern I -- and probably you if you're reading this -- was used to using. `const myMap: Map = new Map()`, then later loop over it with `myMap.forEach((val, key) => {... and TypeScript is happy here ...})` – Erdős-Bacon Jan 03 '22 at 23:15
  • `Object.fromEntries` has just the same issue – chpio Jul 13 '22 at 11:07
2

Possible solution

const isName = <W extends string, T extends Record<W, any>>(obj: T) =>
  (name: string): name is keyof T & W =>
    obj.hasOwnProperty(name);

const keys = Object.keys(x).filter(isName(x));
  • +1 but it's just stupid, that we need this workaround. `Object.keys` is already doing exactly that: "The Object.keys() method returns an array of a given object's *own enumerable property names* [...]" from MDN – chpio Jul 13 '22 at 11:03
0

I had this issue too, so I wrote some typed functions.

Knowing that Object.keys and Object.entries return all keys as string I created a ToStringKey type:

/**
 * Returns the names of the _typed_ enumerable string properties and methods of an object.
 *
 * Note: Limiting Object.keys to a specific type may lead to inconsistencies between type-checking and runtime behavior.
 * Use this function when you are certain of the objects keys.
 */
export const getTypedKeys = Object.keys as <T extends object>(
  obj: T
  // Using `ToStringKey` because Object.keys returns all keys as strings.
) => Array<ToStringKey<T>>;

/**
 * Returns an array of _typed_ values of the enumerable properties of an object.
 */
export const getTypedValues = Object.values as <T extends object>(obj: T) => Array<T[keyof T]>;

/**
 * Returns an array of _typed_ key/values of the enumerable properties of an object.
 *
 * Note: Limiting Object.entries to a specific type may lead to inconsistencies between type-checking and runtime behavior.
 * Use this function when you are certain of the objects keys.
 */
export const getTypedEntries = Object.entries as <T extends object>(
  obj: T
  // Using `ToStringKey` because Object.entries returns all keys as strings.
) => Array<[ToStringKey<T>, T[keyof T]]>;

/**
 * Converts object keys to their string literal types.
 */
type ToStringKey<T> = `${Extract<keyof T, string | number>}`;

I do not recommend defining these method types globally. Create separate utility functions instead.

While TypeScript can infer and work with types, it cannot determine runtime-specific characteristics like enumerability.

starball
  • 20,030
  • 7
  • 43
  • 238
jasperdunn
  • 81
  • 10
-1

just do this and the problem is gone

declare global {
  interface ObjectConstructor {
    keys<T>(o: T): (keyof T)[]
    // @ts-ignore
    entries<U, T>(o: { [key in T]: U } | ArrayLike<U>): [T, U][]
  }
}

I added // @ts-ignore because ts would tell me this:

Type 'T' is not assignable to type 'string | number | symbol

If someone have a solution to get rid of // @ts-ignore without loosing the ability to preserve the dynamic aspect of T, let us know in the comments

If this breaks your code you can do:

Object.tsKeys = function getObjectKeys<Obj>(obj: Obj): (keyof Obj)[] {
 return Object.keys(obj!) as (keyof Obj)[]
}
// @ts-ignore
Object.tsEntries = function getObjectEntries<U, T>(obj: { [key in T]: U }): [T, U][] {
 return Object.entries(obj!) as unknown as [T, U][]
}
declare global {
 interface ObjectConstructor {
   // @ts-ignore
   tsEntries<U, T>(o: { [key in T]: U }): [T, U][]
   tsKeys<T>(o: T): (keyof T)[]
 }
}
Kevin
  • 89
  • 1
  • 3