1

I'd like to create a function in Typescript whose output type depends on the input type. But I'd also like the typings to include intersections of the input types. It looks like I can almost do this with an intersection type. However, this won't generate an overload for the intersection of the factories:

For example:

interface InputA {
  hello: string;
}

interface OutputA {
  world: string;
}

interface FactoryA {
  create(input: InputA): OutputA;
}

interface InputB {
  foo: string;
}

interface OutputB {
  bar: string;
}

interface FactoryB {
  create(input: InputB): OutputB;
}

interface InputC {
  foo: string;
  baz: string;
}

interface OutputC {
  blah: string;
}

interface FactoryC {
  create(input: InputC): OutputC;
}

// Example desired inputs/outputs

const combinedFactory: CombinedFactory<[FactoryA, FactoryB, FactoryC]> = ...;

// OutputA
combinedFactory.create({ hello: ''});

// OutputB
combinedFactory.create({ foo: ''});

// OutputA & OutputB
combinedFactory.create({ hello: '', foo: ''});

// OutputB & OutputC
combinedFactory.create({ foo: '', baz: ''});

// OutputA & OutputB & OutputC
combinedFactory.create({ hello: '', foo: '', baz: ''});

// Error: Does not satisfy InputA | InputB | InputC
combinedFactory.create({ nope: '' });

I could write the overloads manually, but this can get cumbersome if I then introduce more factory types.

Is there a convenient way to implement this in typescript?

danronmoon
  • 3,814
  • 5
  • 34
  • 56
sthomps
  • 4,480
  • 7
  • 35
  • 54
  • What is the expected behavior and what are you getting instead? It's unclear with the current state of the post. – kelsny Feb 09 '23 at 19:01
  • Sorry if the wording is unclear. I'd like to write a function who's inputs could satisfy `InputA | InputB | (InputA & InputB)`. The output type of the function could be either `OutputA`, `OutputB` or `OutputA & OutputB` depending on the inputs type. Does that make sense? – sthomps Feb 09 '23 at 19:08
  • 1
    This is quite involved; does [this approach](https://tsplay.dev/mxj6Zw) meet your needs? It should scale to any number of factory types in a tuple. If it works for you I could write up an answer explaining; if not, what am I missing? – jcalz Feb 10 '23 at 04:30
  • Interesting. Thank you @jcalz let me try this out on my end and see how far I get with it. – sthomps Feb 13 '23 at 20:43
  • Hi @jcalz from playing around with your code it looks like it works if there are no intersections between Input types. If there is an intersection, then the output type is not correct. Hopefully [this](https://tsplay.dev/Wo88Lw) highlights the problem. – sthomps Feb 17 '23 at 20:48
  • No, I don't get it. Your `InputC` extends `InputB`, meaning that every value of type `InputC` is also a value of type `InputB` (see [here](https://tsplay.dev/w252bW)). So if you pass in an `InputC` the function is doing the only reasonable thing here: outputting both `OutputB` and `OutputC`. What type are you expecting and why? (And btw details of questions on SO tend to time out of my brain after some small number of days without looking at it, so please consider responding sooner than later so I don't have to re-learn everything again) – jcalz Feb 17 '23 at 21:03
  • Sorry, my mistake. It does look like your answer is correct and I have misunderstood. Yes, this does meet the needs. Thank you. – sthomps Feb 17 '23 at 21:56
  • Okay I’ll write up an answer when I get a chance. – jcalz Feb 17 '23 at 22:03
  • Hm, [this example](https://tsplay.dev/mAJ8vW) I think may not return the correct type. In this example, InputB arguments are not fully satisfied however OutputB type is included. – sthomps Feb 17 '23 at 22:09
  • I’ll have to look tomorrow, but I have an idea what I need to fix. – jcalz Feb 17 '23 at 22:40
  • Okay [this](https://tsplay.dev/mpj0gw) is the fix for that, I think. I probably can't get back to this until tomorrow so let me know if you find anything else in the meantime. – jcalz Feb 17 '23 at 23:03
  • Does that work for you or not? I don't want to write up a whole answer with an explanation only to find that there's some other important use case that's unsatisfied. Also, please [edit] the question to include the use cases you're asking about in the comments so that the answer will address the question and not comments (which are technically ephemeral and could be deleted at any time by mods) – jcalz Feb 18 '23 at 15:30
  • 1
    Great. I've updated the question to include more test cases. Your updated solution satisfies all these cases. Much appreciated. – sthomps Feb 18 '23 at 16:04

2 Answers2

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • One thing that's unclear is how to write a class that implements the combined factory interface. In this [playground link](https://tsplay.dev/mZ1y1N) my IDE autogenerated the create() function signature. However, when I try to return the output object I can't get the typings right. – sthomps Feb 22 '23 at 21:06
  • You'll probably have to use the equivalent of [type assertions](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions); there's no way the compiler can verify that an implementation matches such a complicated call signature. But this is out of scope for the question that's asked and comments sections aren't great places to get help on followup questions, so if you need further assistance you might want to post a new question. – jcalz Feb 22 '23 at 21:08
0

You can accomplish that with generics:

Notice Factory<InputA & InputB, OutputA & OutputB> which adds desired overload.

interface Factory<InputType, OutputType> {
   create(input: InputType): OutputType;
}

type CombinedFactory = Factory<InputA, OutputA> & Factory<InputB, OutputB> & Factory<InputA & InputB, OutputA & OutputB>;

const combinedFactory: CombinedFactory = ...;

// Returns OutputA
const result1 = combinedFactory.create({ hello: 'hello'});

// Returns OutputB
const result2 = combinedFactory.create({ foo: 'hello'});

// Returns OutputA & OutputB
const result3 = combinedFactory.create({ hello: 'hello', foo: 'hello'});

Code in TS Playground

Lesiak
  • 22,088
  • 2
  • 41
  • 65
  • This looks great for only 2 factory types. But may become cumbersome if another type is added: `... Factory & Factory & Factory & Factory`. Not sure if typescript provides any utilities that may help with this? – sthomps Feb 09 '23 at 19:36