(In the following I'm using TypeScript 3.2)
The main issue with your question, if I understand it, is the difficulty of choosing the right overload at runtime. It is not one of TypeScript's goals (see Non-Goal #5) to compile type information from TypeScript into JavaScript. The type system added by TypeScript is completely erased at runtime. So, if you want to write compose()
to take a list of functions, somehow you have to be able to inspect those functions at runtime to determine which one should be called on a particular argument. That functionality really doesn't exist in JavaScript, though. Well, you can kind of use the length
property of a function to see how many arguments it expects, but in the examples you gave, each function takes exactly one argument. So we can't use that approach here.
One possible way forward is to add a property to each function. This property would be a method that takes a potential set of arguments and returns true
if those arguments are valid for the function, and false
if they are not. Essentially you're manually adding the necessary inspection ability that is missing from the language.
If we do this, we can make compose()
accept a list of such "argument validating functions", like this:
type ArgValidatingFunction =
((...args: any[]) => any) & { validArgs(...args: any): boolean };
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends
((k: infer I) => void) ? I : never;
function compose<F extends ArgValidatingFunction[]>(...fn: F): UnionToIntersection<F[number]>;
function compose(...fn: ArgValidatingFunction[]): Function {
return Object.assign(
(...args: any[]) => (fn.find(f => f.validArgs(...args))!(...args)),
{ validArgs: (...args: any[]) => fn.some(f => f.validArgs(...args)) }
);
}
The type signature for compose
accepts a list of ArgValidatingFunction
arguments and returns the intersection of its elements. TypeScript represents overloads as an order-dependent intersection of signatures. I can't 100% guarantee that the compiler will produce the same overload order as the functions passed in, but it seems to work in my testing.
The implementation of compose
makes use of the ArgValidatingFunction
's validArgs
method, and does a find()
on the passed-in functions to choose the proper function. I also implement a validArgs()
method on the returned function so that the return value of compose()
is also an ArgValidatingFunction
(which is good because the type signature claims that it is).
Now we can try to use it, but it's not trivial... we have to add those methods:
const operationA = ({ a, b, c }: { a: any, b: any, c: any }): 'alpha' => 'alpha';
operationA.validArgs = (...args: any[]) =>
(args.length === 1) && ('a' in args[0]) && ('b' in args[0]) && ('c' in args[0]);
const operationB = ({ a, b }: { a: any, b: any }): 'beta' => 'beta';
operationB.validArgs = (...args: any[]) =>
(args.length === 1) && ('a' in args[0]) && ('b' in args[0]);
const operationC = ({ a }: { a: any }): 'gamma' => 'gamma';
operationC.validArgs = (...args: any[]) =>
(args.length === 1) && ('a' in args[0]);
Here we go:
const operation = compose(operationA, operationB, operationC);
const beta = operation({ a: 3, b: 3 }); // "beta" at compile time;
console.log(beta); // "beta" at runtime
Looks like it works both at compile time and runtime.
So that's one way to go. It's not easy or pretty, but maybe it works for your (or someone's) use case. Hope that helps. Good luck!