1

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 Typescript@4.5.5

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.

Devin Bidwell
  • 100
  • 2
  • 8
  • 1
    Does [this approach](https://tsplay.dev/m35Ejw) meet your needs? If so I can write up an answer; if not, what am I missing? If you want an explicit reformulation of `on()` then maybe you can flesh out that code into a [mre] suitable for pasting into a standalone IDE (so no `P2PConnectionEventMap` unless you're defining that). – jcalz Feb 09 '22 at 15:18
  • Doesn't look like this is the approach that works. I have updated a new TypeScript playground to have a basic class overview of what I'm trying to achieve here => https://tsplay.dev/NdjOyW The goal is to only allow keys with values of functions to be shown in intellisense. I will later be allowing users to dig deaper into the "namespaces" to emit / listen to events from farther within – Devin Bidwell Feb 09 '22 at 21:40
  • 1
    I mean, you're writing `keyof TypedFuncKeys` for some reason, which is like, the keys of keys. `keyof {a: 123}` is `"a"`, but `keyof keyof {a: 123}` is the same as `keyof string`. Not sure why you're doing that. And `TypedFuncKeys[E]` is similarly bizarre, since again, you're indexing into *keys*. Presumably you want [this](https://tsplay.dev/w8JV9m) instead? – jcalz Feb 10 '22 at 03:46
  • Sonofa... That was it! That's the answer! I _really_ need to read up on my TypeScript typing cause I don't understand WHAT the heck that is doing. – Devin Bidwell Feb 10 '22 at 03:55
  • So, for the answer, should I just present the definition of `FuncKeys` and how it gives you a union of the property keys of `T` where the corresponding property values of `T` are functions? Or do you want the whole `P2PConnection***` bit written out? If it's the latter, please [edit] the question to define everything necessary (a Playground link in the comments is great but is not a substitute for having the details of the question contained in the post itself). Let me know. – jcalz Feb 10 '22 at 04:04
  • I think that the explanation behind the `FuncKeys` would be the best answer to this. – Devin Bidwell Feb 10 '22 at 04:11

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360