Here's one possible approach, although it's fairly involved:
interface Factory<I, O> {
create(input: I): O;
}
type CombinedFactory<F extends Factory<any, any>[]> = {
create<I extends { [N in keyof F]:
F[N] extends Factory<infer IN, any> ?
IN : never
}[number]>(
input: I
): I extends any ? { [N in keyof F]:
F[N] extends Factory<infer IN, infer ON> ? I extends IN ?
(x: ON) => void : never : never
}[number] extends (x: infer O) => void ? O : never : never;
};
First, CombinedFactory<F>
is generic in F
, which is constrained to be a tuple of Factory
types. It has a generic create()
method which is generic in I
, the allowable type of the input
parameter.
The constraint on I
involves { [N in keyof F]: F[N] extends Factor<infer IN, any> ? IN : never }
, which is a mapped tuple type which iterates over each numeric-like index N
from the F
tuple, and maps the element at that index to just the factory input type there IN
. So it's a tuple of input types. Then we index into it number
to get the union of those factory input types. Thus I
must be assignable to the union of allowable factory inputs from the F
tuple.
The output type is even more involved. First it is wrapped in a seemingly useless I extends any ? ... : never
, which is in actuality a distributive conditional type which causes unions in I
to lead to unions in the output. So if create(i1)
outputs type O1
, and create(i2)
output types (O2)
, then create(Math.random()<0.5?i1:i2)
will output type O1 | O2
.
Anyway, inside that type we have another mapped tuple. This time, for each numeric-like index N
, it infers the input type IN
and output type ON
for the corresponding F[N]
element of the F
tuple. It then checks to see if I
(which can't be a union now because of the outermost distributive conditional type) is assignable to the factory input type IN
. If so, then we want to hold onto ON
for the output, otherwise we discard it to never
. What we actually do is map ON
to a function parameter (x: ON) => void
, so we end up with a tuple with all the applicable factory output types as function parameters, and the inapplicable ones as never
. So if F
is [Factory<I1,O1>, Factory<I2,O2>, Factory<I3,O3>]
, and I
is I1 & I3
, then the mapped type is [(x: O1)=>void, never, (x: O3)=>void]
. The reason why we put these in functions is because these are contravariant type positions (as mentioned in Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript ) and this will be used later to give us intersections.
So now we take that mapped tuple and index into it with number
to give us the unions of those functions (so ((x: O1) => void) | ((x: O3) => void)
in the example here), and infer that against a single function parameter input. Because of the way conditional type inference works, this gives us the intersection O1 & O3
. (This is the same basic technique described in Transform union type to intersection type .)
So a union in I
should return a union output, and intersections in union members of I
should return intersections in union members of the output.
Let's test it out with your examples:
declare const combinedFactory:
CombinedFactory<[FactoryA, FactoryB, FactoryC]>;
const oA = combinedFactory.create({ hello: '' });
// const oA: OutputA
const oB = combinedFactory.create({ foo: '' });
// const oB: OutputB
const oAandB = combinedFactory.create({ hello: '', foo: '' });
// const oAandB: OutputA & OutputB
const oAorB = combinedFactory.create(Math.random() < 0.5 ? { hello: "" } : { foo: "" });
// const oAorB: OutputA | OutputB
const oBandC = combinedFactory.create({ foo: '', baz: '' });
// const oBandC: OutputB & OutputC
const oAandBandC = combinedFactory.create({ hello: '', foo: '', baz: '' });
// const oABC: OutputA & OutputB & OutputC
combinedFactory.create({ nope: '' }); // error!
// Argument of type '{ nope: string; }' is not assignable to
// parameter of type 'InputA | InputB | InputC'.
Looks good. The intersection and union of InputA
and InputB
produces, respectively, the intersection and union of OutputA
and OutputB
. The input of type InputC
produces OutputB & OutputC
because InputC
is assignable to InputB
. And the input of type InputA & InputB & InputC
produces the output of OutputA & OutputB & OutputC
. And the bad input is rejected because it's not assignable to the union of allowable input types.
Playground link to code