5

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?

Simon Hessner
  • 1,757
  • 1
  • 22
  • 49
  • Follow-up question: https://stackoverflow.com/questions/61787237/typescript-error-when-passing-an-array-in-a-variable-rather-than-direcly-to-a-f – Simon Hessner May 14 '20 at 00:35

1 Answers1

12

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • (Not OP) reading your answers is always an elucidating pleasure. – msanford May 13 '20 at 01:55
  • Thank you. I have some questions. The first is about the first solution: By using `o: Record`, don't I only accept objects that only have keys which map to strings? Clearly, your code works, so apparently my assumption is wrong, but I don't really understand why. Is `Record` a supertype of `` which would explain why objects containing more than only string values are accepted. – Simon Hessner May 13 '20 at 22:14
  • `Record` means "for the keys in `K`, the values are `string`". It doesn't have any implications about keys *not* in `K`. Object types in TypeScript aren't ["exact"](https://github.com/microsoft/TypeScript/issues/12936); they are allowed to have extra properties (the [excess property checking](https://www.typescriptlang.org/docs/handbook/interfaces.html#excess-property-checks) done in certain circumstances notwithstanding). So a value of type `{a: number, b: string, c: string}` is assignable to a variable of type `{b: string}` aka `Record<'b', string>`. – jcalz May 14 '20 at 01:33
  • Hi, I am wondering how to also accept `string | null` properties. While the above works for `KeysMatching` and string values, it does not work for `KeysMatching` or `KeysMatching>`, where `Maybe = T | null`. `Maybe` values are not accepted. I tried to alter it to `{ [K in keyof T]-?: T[K] extends Maybe ? K : never; }[keyof T]` but that does not work either. Any idea? – psteinroe Feb 15 '21 at 06:35
  • @Steinroe without a [mcve] it's hard to say; what is `MyObject`? I couldn't easily reproduce, so ‍♂️. Generally speaking comments aren't the best place to do this, but if you can manage to get a reproducible example for me via something like [the TS Playground link shortener](https://tsplay-dev.now.sh/) I might give it a shot. Otherwise you should consider opening a new question if you can't figure it out. Good luck! – jcalz Feb 15 '21 at 15:52
  • Thanks for the offer! While creating the sample I realised that the prop types of MyObjects properties was null | undefined | string - so adding undefined to the union fixed it. Thanks! – psteinroe Feb 15 '21 at 20:25