The decision to maintain the current return type (string[]
) is deliberate. But why?
Let's look at an example type:
interface Point {
x: number;
y: number;
}
Suppose you have a function 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");
}
}
Here's a question for you:
In a well-typed program, can a legitimate call to fn
result in an error?
The expected answer is obviously "No". So, what does this have to do with Object.keys
?
Now, consider the following code snippet:
interface NamedPoint extends Point {
name: string;
}
const origin: NamedPoint = { name: "origin", x: 0, y: 0 };
Note that as per TypeScript's type system, all instances of NamedPoint
are valid instances of Point
.
Let's add a bit more code:
function doSomething(pt: Point) {
for (const k of Object.keys(pt)) {
// A proper call only if Object.keys(pt) returns (keyof Point)[]
fn(k);
}
}
// Throws an exception
doSomething(origin);
Our perfectly typed program just threw an exception!
There seems to be an issue here!
By using keyof T
, we've breached the assumption that keyof T
constitutes an exhaustive list. This is because having a reference to an object doesn't guarantee that the type of the reference is not a supertype of the type of the value.
In essence, at least one of the following statements must be false:
keyof T
represents an exhaustive list of keys of T
- A type with additional properties is always a subtype of its base type
- It is permissible to alias a subtype value with a supertype reference
Object.keys
should return keyof T
Discarding point 1 renders keyof
almost useless since it suggests that keyof Point
could be something other than "x"
or "y"
.
Getting rid of point 2 would dismantle TypeScript's type system completely.
Similarly, abolishing point 3 would also demolish TypeScript's type system entirely.
However, discarding point 4 is acceptable and prompts you, the programmer, to contemplate whether the object you're dealing with might be an alias for a subtype of the intended target.
The hypothetical solution to make this scenario legitimate without contradiction lies in Exact Types, which would enable the declaration of a new distinct type exempt from point #2. With this feature, it might be viable for Object.keys
to solely represent keyof T
for declared exact types of T
.
Addendum: What about generics?
Some individuals suggested that Object.keys
could appropriately return keyof T
if the argument was generic. However, that assertion is inaccurate. Here's why:
class Holder<T> {
value: T;
constructor(arg: T) {
this.value = arg;
}
getKeys(): (keyof T)[] {
// Assumed to be correct
return Object.keys(this.value);
}
}
const MyPoint = { name: "origin", x: 0, y: 0 };
const h = new Holder<{ x: number, y: number }>(MyPoint);
// 'name' value assigned to 'x' | 'y' variable
const v: "x" | "y" = (h.getKeys())[0];
Or consider this example, where explicit type arguments are unnecessary:
function getKey<T>(x: T, y: T): keyof T {
// Believed to be feasible
return Object.keys(x)[0];
}
const obj1 = { name: "", x: 0, y: 0 };
const obj2 = { x: 0, y: 0 };
// 'name' value inhabits 'x' | 'y' typed variable
const s: "x" | "y" = getKey(obj1, obj2);