18

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.

Lionel Tay
  • 1,274
  • 2
  • 16
  • 28

2 Answers2

28

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Great answer, interesting use of conditional types. I was just wondering in the second version with the more complex signature, although `key` is restricted to a key of `obj` that has a number value, when you do something like `const x = obj[key]` `x` will be of type `T[K]` not number. Is there anything that can be done there? – Lionel Tay Sep 06 '18 at 09:45
  • `T[K]` is possibly more specific than `number`. I added an update. – jcalz Sep 06 '18 at 12:52
  • This answer is impressive but could you detail what the complicated part does in the conditional type part, please? It works but I'd like to understand why it works. – Telokis Jun 27 '20 at 18:25
  • 1
    The type `{[K in keyof T]: T[K] extends number ? K : never}[keyof T]` is usually what I'd call `KeysMatching`. The type `T[K] extends number ? K : never` will be equal to the key if its property is of type `number`; otherwise it will be `never`. So if `T` is `{a: string, b: number}` and `K` is `"a"` then it becomes `never`, and if `K` is `"b"` then it becomes `"b"`. The mapped type `{[K in keyof T]: T[K] extends number ? K : never}` makes that the new property values: `{a: never, b: "b"}`. And the `[keyof T]` just gets the union of property type: `never | "b"` which equals `"b"`. – jcalz Jun 27 '20 at 21:37
  • Read about [keyof/lookup types](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-1.html#keyof-and-lookup-types), [mapped types](https://www.typescriptlang.org/docs/handbook/advanced-types.html#mapped-types), and [conditional types](https://www.typescriptlang.org/docs/handbook/advanced-types.html#conditional-types). @Telokis – jcalz Jun 27 '20 at 21:39
  • Thank you very much for the details, it's much clearer now! Is the `keyof any & {...}` a trick or something? I never saw it before – Telokis Jun 28 '20 at 00:26
  • Wouldn't `, K extends keyof T>` work best? – ruohola Sep 20 '21 at 13:04
  • @ruohola Does it? If you do that sometimes the error message is [a little weirder](https://tsplay.dev/m3zgLN). I suppose whether it's better or worse depends on the use case. – jcalz Sep 20 '21 at 14:37
  • @jcalz Ahh, that is a bit weird, but it works perfectly for my use case where I use `unknown` instead of `number`. This is anyways the more general solution: https://www.typescriptlang.org/play?#code/GYVwdgxgLglg9mABAcwKZQHIgLYCNUBOAYgXNgPK4BWq0APACqKoAeUqYAJgM6IBKtOAU50A0gBpE4ANZg4AdzAA+SaOZsOPRNNQBPOMEQMlACgCwAKESI41AFxHxl6zt0PRlgJQOGAbVEAuogA3s6IBOggBEi2VL6uAZYAvpaWaJg4+MSkFNS0UCbBiJxwyA4ATJIQAIZQDgBEABZ69ZLYcHDcqBUALIhJkvU1UPWeANzWAPSTNtJp6Fh4hCRklDTQhcWlFVW1Dc26rYjtnd2I5X0DiPXAHaMTiNMoHZzMBKQEx6jc3NVoQA – ruohola Sep 20 '21 at 14:42
4

Best solution:

const getNumberFromObject = <T extends Record<K, number>, K extends keyof T>(
  obj: T,
  key: K,
) => {
  return obj[key];
};
Alexander Danilov
  • 3,038
  • 1
  • 30
  • 35