Typescript: constrain argument of function to be a key of an object associated with a value of a particular type

Lionel Tay picture Lionel Tay · Sep 5, 2018 · Viewed 7.8k times · Source

Is there a way to make the following type check?

function getNumberFromObject<T>(obj: T, key: keyof T): number {
  return obj[key] // ERROR: obj[key] might not be a number
}

I want to specify that key should not only be a key of T, but a key with a number value.

Answer

jcalz picture jcalz · Sep 5, 2018

The most straightforward way to do this so that both the callers and the implementation of getNumberFromObject<T> type check correctly is this:

function getNumberFromObject<T extends Record<K, number>, K extends keyof any>(
  obj: T, 
  key: K
): number {
  return obj[key] // okay
}

And when you call it:

getNumberFromObject({dog: 2, cat: "hey", moose: 24}, "dog"); // okay
getNumberFromObject({dog: 2, cat: "hey", moose: 24}, "cat"); // error
getNumberFromObject({dog: 2, cat: "hey", moose: 24}, "moose"); // okay
getNumberFromObject({dog: 2, cat: "hey", moose: 24}, "squirrel"); // error

That all works well, except that the errors you get are a little obscure in that it complains that Object literal may only specify known properties, and 'dog' does not exist in type 'Record<"somebadkey", number>'. This complaint is an excess property check and isn't really the issue.

If you want to make it so that callers get a better error, you could use a more complicated conditional type like this:

function getNumberFromObject<T, K extends keyof any & {
  [K in keyof T]: T[K] extends number ? K : never
}[keyof T]>(
  obj: T,
  key: K
): T[K] {
  return obj[key] // okay
}

getNumberFromObject({ dog: 2, cat: "hey", moose: 24 }, "dog"); // okay
getNumberFromObject({ dog: 2, cat: "hey", moose: 24 }, "cat"); // error
getNumberFromObject({ dog: 2, cat: "hey", moose: 24 }, "moose"); // okay
getNumberFromObject({ dog: 2, cat: "hey", moose: 24 }, "squirrel"); // error

In this case, T is unconstrained, but K is forced to be just those keys from T where T[K] is a number.

Now the error says Argument of type '"somebadkey"' is not assignable to parameter of type '"dog" | "moose"'., which is more developer-friendly. Not sure if the extra complexity of signature is worth it to you, though.

Hope that helps. Good luck!


Update: The latter function returns T[K], not number. That could be a good thing, since T[K] is possibly more specific than number. For example:

interface Car {
  make: string,
  model: string,
  horsepower: number,
  wheels: 4
}
declare const car: Car;
const four = getNumberFromObject(car, 'wheels'); // 4, not number

The value four is of type 4, which is more specific than number. If you really want to widen the return type of the function to number, you can... although the implementation will balk at that since the compiler isn't smart enough to realize that T[K] is assignable to number in the generic case. There are ways to deal with that, but the easiest is to use a type assertion in the implementation (return obj[key] as any as number).

Playground link to code