HiveBrain v1.2.0
Get Started
← Back to all entries
patterntypescriptCriticalCanonical

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

Submitted by: @import:stackoverflow-api··
0
Viewed 0 times
typescriptobjectreturnkeyskeyofwhydoesntype

Problem

Title says it all - why doesn't Object.keys(x) in TypeScript return the type Array? 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?

Solution

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:

  • keyof T is an exhaustive list of the keys of T



  • A type with additional properties is always a subtype of its base type



  • It is legal to alias a subtype value by a supertype reference



  • 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 {
    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(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(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);

Code Snippets

interface Point {
    x: number;
    y: number;
}
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");
    }
}
interface NamedPoint extends Point {
    name: string;
}

const origin: NamedPoint = { name: "origin", x: 0, y: 0 };
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);
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];

Context

Stack Overflow Q#55012174, score: 258

Revisions (0)

No revisions yet.