Why doesn't Object.keys return a keyof type in TypeScript?
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 NamedPoint
s are valid Point
s.
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:
keyof T
is an exhaustive list of the keys ofT
- 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
returnskeyof 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 T
s 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);
I'll try to explain why object keys cannot return keyof T while being safe with some simple example
// we declare base interface
interface Point {
x: number;
y: number;
}
// we declare some util function that expects point and iterates over keys
function getPointVelocity(point: Point): number {
let velocity = 0;
Object.keys(point).every(key => {
// it seems Object.keys(point) will be ['x', 'y'], but it's not guaranteed to be true! (see below)
// let's assume key is keyof Point
velocity+= point[key];
});
return velocity;
}
// we create supertype of point
interface NamedPoint extends Point {
name: string;
}
function someProcessing() {
const namedPoint: NamedPoint = {
x: 5,
y: 3,
name: 'mypoint'
}
// ts is not complaining as namedpoint is supertype of point
// this util function is using object.keys which will return (['x', 'y', 'name']) under the hood
const velocity = getPointVelocity(namedPoint);
// !!! velocity will be string '8mypoint' (5+3+'mypoint') while TS thinks it's a number
}