Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why doesn't Object.keys return a keyof type in TypeScript?

Tags:

typescript

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?

like image 463
Ryan Cavanaugh Avatar asked Mar 05 '19 21:03

Ryan Cavanaugh


People also ask

What is Keyof Typeof in TypeScript?

The keyof type operatorThe keyof operator takes an object type and produces a string or numeric literal union of its keys. The following type P is the same type as “x” | “y”: type Point = { x : number; y : number }; type P = keyof Point ; type P = keyof Point.

Is Typeof TypeScript?

TypeScript comes with some built-in type guards: typeof and instanceof . They're very useful, but have limited scope. For example, typeof can only be used to check string , number , bigint , function , boolean , symbol , object , and undefined types.


2 Answers

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 iff 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);
like image 50
Ryan Cavanaugh Avatar answered Oct 16 '22 09:10

Ryan Cavanaugh


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;
}
like image 28
rattray Avatar answered Oct 16 '22 09:10

rattray