Reputation: 100
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
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.
Upvotes: 1