1

EDIT: I have updated the entire question based on the discussions in the comments.

I'm trying to create a type for a function that looks like: (eventName: EventNames, ...params: [ParamsTypeForEventName]) => void

I want the eventName to be enough to uniquely determine the types of params that are needed for that event.

I have access to a type of the form:

type T1 = {
    //eventName: array of param types
    [eventName: string]: any[]
};

I want to use this type T1 to generate the type for the function.

This is what I have tried:

const names = Object.freeze({
    n1: 'n1',
    n2: 'n2'
});

type T1 = {
    [names.n1]: [arg0: number],
    [names.n2]: [arg0: string, arg1: boolean]
};

type ValueOf<T> = T[keyof T];

type ObjectWithFunc<T extends {[name: string]: any[]}> = {
    func: ValueOf<{
        [Name in keyof T]: ((name:Name, ...args:T[Name])=>void)
    }>
}

let objectWithFunc:ObjectWithFunc<T1>;
objectWithFunc = { func: ()=>{} };

objectWithFunc.func('n1',10);

type ExtractedType = typeof objectWithFunc.func;

Now as expected if you hover on ExtractedType you'll see the type:

type ExtractedType = ((name: "n1", arg0: number) => void) | ((name: "n2", arg0: string, arg1: boolean) => void)

resolving this type leads to:

(property) func: (name: never, arg0: never, arg1: boolean) => void

I don't want the parameters of the union of the functions to be intersected.

I want to union to behave as if the functions were atomic types and any one of them could be chosen.

kharon4
  • 13
  • 4
  • This is all working as intended; unions of functions are not easily callable, and where they are callable, they require an *intersection* of the parameters from the union members. That gives you `"n1" & "n2"`, which is `never`, for the first one. See [the doc for calling unions of functions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-3.html#improved-behavior-for-calling-union-types). – jcalz Jul 14 '22 at 18:59
  • Your example code is... strange. Why is `ObjectWithFunc` generic if it ignores the generic type parameter? – jcalz Jul 14 '22 at 19:00
  • I'd tell you how to get it working if I understood what you were trying to do. A union of functions is hardly ever useful, though, and you're not really using it... the function you put in there doesn't do anything, and since it's not safe to call it, I don't know what it should actually do, anyway. – jcalz Jul 14 '22 at 19:02
  • Hi @jcalz ! Thanks for the link to the doc, I can totally see the issue now. – kharon4 Jul 14 '22 at 19:30
  • @jcalz So what I was trying to do was to make a custom event system. I wanted the event name to be enough to determine the types of parameters that are needed for that event. But I can totally see how this would not work if intersection of parameters are considered. Is there any other way to accomplish this ? Sorry I'm new to TS. – kharon4 Jul 14 '22 at 19:34
  • Maybe, but you should consider [edit]ing the question to ask how to do what you're trying to do, so this isn't an XY problem. Comments aren't the best place to do this. – jcalz Jul 14 '22 at 19:36
  • Does [this approach](https://tsplay.dev/NrnA5N) meet your needs? If so, I would be happy to write up an answer explaining it, if you would first [edit] the question to ask for what you're looking for (e.g., a function where the event name determines etc etc) and possibly what you tried to do to get there (i.e., a union of functions, but that gives an intersection of parameters, etc). I'm thinking you should remove references to the current behavior being incorrect or inconsistent, since that's not what's happening and not what you want to do anyway. – jcalz Jul 15 '22 at 01:36
  • @jcalz yes !!! This is exactly what I was looking for ! Thank you :). I've updated the question. I understand how you made it work... But it would be great if you could write an answer, I think that would give a proper closure to this thread. – kharon4 Jul 15 '22 at 12:00

1 Answers1

2

The conventional way to do this is to make the func method generic in the type of the name parameter which should be constrained to keyof T, like this:

type ObjectWithFunc<T extends { [name: string]: any[] }> = {
    func: <Name extends keyof T>(name: Name, ...args: T[Name]) => void
}

declare let objectWithFunc: ObjectWithFunc<T1>;

objectWithFunc.func('n1', 10); // okay
objectWithFunc.func('n2', "abc", true); // okay
objectWithFunc.func('n2', 123); // error!

From a type system point of view, you're trying to say that the func method of a value of type ObjectWithFunc<T> should behave like (name: K, ...args: T[K])=>void for every key K in keyof T. That's basically an intersection of function types, and thus corresponds to overloads. For example:

type OWFT1Func =
    ((name: 'n1', arg0: number) => void) &
    ((name: 'n2', arg0: string, arg1: boolean) => void);

let f: OWFT1Func = objectWithFunc.func; // this assignment is allowed
f('n1', 10); // okay
f('n2', "abc", true); // okay
f('n2', 123); // error!

As you can see, the type OWFT1Func has multiple call signatures and is therefore an overloaded function, and it behaves similarly to the generic version above. You can think of generics in TypeScript as an arbitrary or potentially infinite intersection.

In generic functions and in intersections of functions, the caller can specify the call signature or generic type parameters, and the implementer needs to be able to handle any such choice. Freedom for the caller becomes a constraint for the implementer.


Compare this to what you were trying to do: your ValueOf<{ [Name in keyof T]: ((name:Name, ...args:T[Name])=>void) }> is a union of function types instead of an intersection. Thus, instead of saying that it should behave like every call signature you care about, you were saying that it behaves like some call signature you care about. That's not what you meant.

And it's also not a very useful type for a caller; if all you know is that you have some call signature from a set, you quite often cannot call it at all, since you don't know which one it is. The only way to safely call a union of functions is if there's some call that would work for every possible call signature at once. Meanwhile the implementer can just happily pick one of the functions to implement and ignore the others. Freedom for the implementer becomes a constraint for the caller.


Notice the duality here: an intersection of functions lets the caller pick some call signature, while the implementer needs to write something that works for all of them at once; a union of functions lets the implementer pick some call signature, while the caller needs to write something that works for all of them at once. Another way to say this is that, in theory, an intersection of functions accepts a union of parameters, and a union of functions accepts an intersection of parameters.

This is a consequence of the contravariance of function types in the types of their parameters. (See Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript for more information). Contravariance can be confusing, since it inverts the relationship between types. If F<T> is contravariant in T, then X extends Y implies F<Y> extends F<X> and not vice versa. Since string is assignable to string | number, then the function type (x: string | number) => void is assignable to (x: string) => void and not vice versa. So that's a possible source of your trouble when trying to solve this.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360