'TypeScript: Accept all Object keys that map to a specific type

Given an object type (or class type), I want to write a function that accepts the object and a list of its keys. However, I only want to allow keys that map to a value of a specific type, e.g. only strings.

Example:

function shouldOnlyAcceptStringValues(o, key) {
    // Do something with o[key] that depends on the assumption that o[key] has a specific type, e.g. string
}

const obj = {
    a: 1,
    b: "test",
    c: "bla"
}

const key = "c" as const;
shouldOnlyAcceptStringValues(obj, key);  // b and c should be accepted as keys, a not.

I know a way to enforce that key actually exists on o (regardless of the type of o[key]):

function shouldOnlyAcceptStringValues<T>(o: T, key: keyof T) {
    // Do something with o[key] that depends on the assumption that o[key] has a specific type, e.g. string
}

However, this would also allow the use of key="a" although that maps to a number.

What I need is something like this:

function shouldOnlyAcceptStringValues<T, K extends keyof T, T[K] extends string>(o: T, key: K)

But that is of course not valid TypeScript code.

Is there a trick how to make that work? I need a way to further refine the set of keys keyof T. The body of the function should then know that o[key] is a string without explicitly checking the type inside the function. Is that somehow possible?



Solution 1:[1]

If you want something that works from both the caller's point of view and from the implementer's point of view, you can do this:

function shouldOnlyAcceptStringValues<K extends PropertyKey>(
  o: Record<K, string>, key: K
) {
    const okay: string = o[key];
}

This is sort of looking at your constraint backwards; instead of constraining key to be the right keys from obj, you are constraining obj to be an object whose value type at key is a string. You can see that okay is accepted as a string, and things work from the caller's side also:

shouldOnlyAcceptStringValues(obj, "a"); // error!
// ------------------------> ~~~
// Argument of type '{ a: number; b: string; c: string; }' is 
// not assignable to parameter of type 'Record<"a", string>'.

shouldOnlyAcceptStringValues(obj, "b"); // okay
shouldOnlyAcceptStringValues(obj, "c"); // okay

The only snag is that the error on the first call is probably not on the argument you expect; it's complaining about obj and not "a". If that's okay, great. If not, then you could change the call signature to be the sort of constraint you're talking about:


type KeysMatching<T, V> = { [K in keyof T]: T[K] extends V ? K : never }[keyof T]
function shouldOnlyAcceptStringValues2<T>(o: T, key: KeysMatching<T, string>): void;
function shouldOnlyAcceptStringValues2<K extends PropertyKey>(
  o: Record<K, string>, key: K
) {
    const okay: string = o[key];
}

The KeysMatching<T, V> type function takes a type T and returns just those keys whose values are assignable to V. And so the call signature will specify T for o and KeysMatching<T, string> for key. Note how I've written that call signature as a single overload and the implementation signature is the same as before. If you don't do that then the compiler is unable to understand that for generic T that T[KeysMatching<T, string>] is assignable to string; it's a higher-order type inference the compiler can't make:

function shouldOnlyAcceptStringValuesOops<T>(o: T, key: KeysMatching<T, string>) {
    const oops: string = o[key]; // error!
    // -> ~~~~
    // Type 'T[{ [K in keyof T]: T[K] extends string ? K : never; }[keyof T]]' 
    // is not assignable to type 'string'. ?
}

See microsoft/TypeScript#30728 for more information.

So in the overloaded version we let the caller see the constraint on key and the implementation see the constraint on obj, which works out better for everyone:

shouldOnlyAcceptStringValues2(obj, "a"); // error!
// ------------------------------> ~~~
// Argument of type '"a"' is not assignable to parameter of type '"b" | "c"'

shouldOnlyAcceptStringValues2(obj, "b"); // okay
shouldOnlyAcceptStringValues2(obj, "c"); // okay

Now the compiler complains about key instead of obj.


Okay, hope that helps; good luck!

Playground link to code

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1