Devin Bidwell
Devin Bidwell

Reputation: 100

Pulling keys out of typescript type/interface where the values are of type T

I'm working on a new library which will contain a set of callback functions. I want to implement a sort of "namespace" feature so interfaces and callback chains don't get so overwhelming.

My base callback / namespace with callback type looks like this:

type AnyFunction = (...args: any) => void;

type DefinedTypeMap = {
  [key: string]: AnyFunction | DefinedTypeMap;
};

Where the then extended interfaces could look like this:

interface IMyCallbackFunctions extends DefinedTypeMap {
  namespace: {
    func: (msg: string) => void;
  };
  root: (msg: string) => void;
  otherRoot: (msg: string) => void;
}

I'm working on a class function that will ONLY accept keys on the object if that value is an AnyFunction type ( Not a nested namespace ).

As the interfaces are defined by the developer using the library ( as long as they extend the required interface ) the accepted function parameters in this case would ONLY be "root" | "otherRoot" I have tried Omit<T, K> as well as looking high and low on stack overflow for a solution ( to no avail ).

This library uses [email protected]

The current function (which does not support the in progress namespace feature) looks like this:

on<E extends keyof P2PConnectionEventMap<T>, F extends P2PConnectionEventMap<T>[E]>(event: E, callback: F) => void

and should be modified to only allow the event param to accept keys where the values on the interface are of the callback type not the object type.

Thanks in advance for the help, this is making me want to pull my hair out.

Upvotes: 1

Views: 478

Answers (1)

jcalz
jcalz

Reputation: 328503

Let's define a type utility called FuncKeys<T> which takes an object type T and returns a union of keys where the type of the property at each such key is assignable to AnyFunction. The goal is that FuncKeys<IMyCallbackFunctions> should evaluate to "root" | "otherRoot" and that it should not include "namespace" (which is a non-function object) or string (from the index signature on the extended DefinedTypeMap interface).

Just to be painfully clear, your IMyCallbackFunctions interface is equivalent to:

interface IMyCallbackFunctions {
  [key: string]: DefinedTypeMap | AnyFunction;
  namespace: {
    func: (msg: string) => void;
  };
  root: (msg: string) => void;
  otherRoot: (msg: string) => void;
}

where I've just explicitly added the index signature.


Often when faced with a question like this I point to the KeysMatching<T, V> utility as given in the answer to In TypeScript, how to get the keys of an object type whose values are of a given type?. But that utility doesn't work on types with string index signatures; the fact that every possible string is a valid key for the object type tends to "wash away" any results from the specific literal keys. So we have to take a different approach.

Here's the implementation I'd go with for your use case:

type FuncKeys<T extends object> = 
  keyof { [K in keyof T as T[K] extends AnyFunction ? K : never]: any };

This uses key remapping to filter out keys where the property is not assignable to AnyFunction. Each key K in the keys of the object type T is "remapped" to either itself (if the property type you get when [indexing into]https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html) T with a key of type K, a.k.a. T[K] is assignable to AnyFunction), or to the never type (otherwise). This effectively keeps only the keys you want and removes the ones you don't. Note that the mapped type we come up with has just any as its property types, but that doesn't matter because we are just grabbing the keys with the keyof type operator.

Let's walk through how this works with IMyCallbackFunctions. It looks like

  keyof { [K in keyof IMyCallbackFunctions as IMyCallbackFunctions[K] extends AnyFunction ? K : never]: any };

so K will iterate through string, "namespace", "root", and "otherRoot". For string and "namespace", the test IMyCallbackFunctions[K] extends AnyFunction ? K : never will evaluate to never, since (DefinedTypeMap | AnyFunction) extends AnyFunction is not true and neither is {func: (msg: string) => void;} extends AnyFunction. So those keys are dropped. For "root" and "otherRoot", the test will evaluate to "root" and "otherRoot" respectively, since ((msg: string) => void) extends AnyFunction is true.

That means we now have

  keyof { root: any, otherRoot: any }

which finally evaluates to "root" | "otherRoot" as desired.


You can then use FuncKeys<T> to make the on() method's call signature only accept keys whose values are function-typed.

Playground link to code

Upvotes: 1

Related Questions