typescriptgenericsunion-types

Exclude keyof interface of just function based on function return type


I have the following interface:

interface MyInterface {
  GetChocolate: () => string;
  GetVanilla: () => number;
  SetChocolate: () => number;
  SetVanilla: () => string;
} 

The interface is only made up of function types. I have a function that takes in a keyof MyInterface and does something with it (doesn't matter what). However, I only want the function to accept keys where the return type is string. I have something like this:

type KeysReturnStrings<K extends keyof MyInterface> = ReturnType<MyInterface[K]> extends string ? K : never;

This works great when I only pass in one key:

type Test1 = KeysReturnStrings<"GetChocolate">; // "GetChocolate"
type Test2 = KeysReturnStrings<"SetChocolate">; // never

But this does not work when I pass in keyof MyInterface:

type Test3 = KeysReturnStrings<keyof MyInterface>; // never

The type of Test3 when I want it to be "GetChocolate" | "SetVanilla".

I can create a different generic type that filters the keys based on if they contain the work "Get":

type KeysNoGet<K extends keyof MyInterface> = K extends `Get${string}` ? never : K;
type Test4 = KeysNoGet<keyof MyInterface>; // "SetChocolate" | "SetVanilla"

I just do not understand why Test4 works but Test3 doesn't. My only guess is that the issue is to do with passing keyof MyInterface into ReturnType<MyInterface[K]> as this creates a union of the return types of that object (string | number) which obviously do not extend type string. But then keyof MyInterface is a union regardless so it shouldn't be able to extend Get${string} either.

Is there a utility type or a another generic type that can exclude these keys from the union type?


Solution

  • You want KeysReturnStrings<K> to distribute over unions in K, so that KeysReturnStrings<K1 | K2 | K3> is equivalent to KeysReturnStrings<K1> | KeysReturnStrings<K2> | KeysReturnStrings<K3>. But your definition does not do so, and you get unexpected results.

    There are a few ways to get type functions to distribute over unions; one of them is to use a distributive conditional type where the type you check is a generic type parameter corresponding to the union you want to distribute over. A type of the form type F<T> = T extends U ? X<T> : Y<T> will distribute over unions in T. Your KeysNoGet<K> is of that form, so that's why it behaves as you expect.

    So we can wrap your definition of KeysReturnStrings<K> in a "no-op" distributive conditional type, which always takes the true branch. We're only using it for its distributive property, not for any sort of conditional type check:

    type KeysReturnStrings<K extends keyof MyInterface> = K extends unknown ?
      ReturnType<MyInterface[K]> extends string ? K : never
      : never;
    

    Now you get the behavior you expect:

    type Test1 = KeysReturnStrings<"GetChocolate">;
    //   ^? type Test1 = "GetChocolate"
    type Test2 = KeysReturnStrings<"SetChocolate">;
    //   ^? type Test2 = never
    type Test3 = KeysReturnStrings<keyof MyInterface>;
    //   ^? type Test3 = "GetChocolate" | "SetVanilla"
    

    Playground link to code