1

I have an array of objects that all implements the same generic Executable interface:

interface Context {}

interface Executable<TContext extends Context> {
  execute(context: TContext): void | Promise<void>;
}

I managed to infer a type that is the intersection of all the contextes passed to the execute function of each Executable in an array:

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
    k: infer I
  ) => void
    ? I
    : never;


type ContextUnion<TExecutables extends Executable<Context>[]> = TExecutables[number] extends Executable<infer TContext> ? TContext : never;

type IntersectContextesOf<TExecutables extends Executable<Context>[]> = UnionToIntersection<
    ContextUnion<TExecutables>
>;

for instance:

interface RedContext { getRed(): string }
const red = { async execute(context: RedContext) {} }

interface BlueContext { getBlue(): string }
const blue = { async execute(context: BlueContext) {} }

const steps = [blue, red];
const context: IntersectContextesOf<typeof steps> = {
  getRed() { return 'red' },
  // Commenting the following line raise a TS error as expected
  // getBlue() { return 'blue' },
}

The const context is properly typed as intersection of blue and red execute's contextes. But this seems to work only because the const steps has no typing.

I tried to type steps like this

const steps: Executable<Context>[] = [blue, red];
const context: IntersectContextesOf<typeof steps> = {
  getRed() { return 'red' },
  // No more TS Error
  // getBlue() { return 'blue' },
}

Then context has no more error with getBlue commented, probably because the inference is turned off by forcing Context in the steps type.

I can't figure out a proper typing for the array of Executable.

The end game is to have an Operation interface with steps and a buildContext method that return a context intersecting all the contextes of the steps:

interface Operation {
  buildContext(): IntersectContextesOf<typeof this['steps']>;
  steps: Executable<Context>[];
}

const operation: Operation = {
  buildContext() {
    return {
      getRed() { return 'red' },
      // getBlue() { return 'blue' },
    }
  },
  steps: [blue, red],
}

But here again, commenting getBlue does not raise a TS error

Bambou
  • 139
  • 1
  • 9
  • Please provide [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example), currently, your code snippets lack some types and functions. Remove those that are irrelevant and add the others – wonderflame May 20 '23 at 09:37
  • @wonderflame thanks for the feedback. I tried to simplify it as much as possible and tested the examples. – Bambou May 20 '23 at 13:52
  • There is no specific `Operation` type that meets your needs, so you'll need a generic `Operation` and maybe a helper function to infer `T` for you. Also your type functions are a bit more complicated than they need to be. See [this playground link](https://tsplay.dev/wXr1km) for how you might want to approach this. Does that fully address the question? If so I'll write up an answer explaining; if not, what am I missing? – jcalz May 20 '23 at 14:21
  • @jcalz thanks for the simplifications, it is a bit sad to have to go through a function to get the typing right but that will do the trick. Thanks again! – Bambou May 20 '23 at 17:51

1 Answers1

0

As an aside, you can simplify your type definitions like this:

interface Executable<TContext extends Context> {
    execute: (context: TContext) => void | Promise<void>; 
}

type ExecutableArray = readonly Executable<never>[];

type IntersectContextsOf<T extends ExecutableArray> =
    T extends { [k: number]: Executable<infer C> } ? C : never

Because function types are contravariant in their parameter types (see Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript ), you get intersections when inferring from them directly, without first converting to a union and then converting back via UnionToIntersection ( as described in Transform union type to intersection type ).


There isn't a specific type in TypeScript that represents your desired behavior for Operation where steps and buildContext() are "connected". In your definition, you're trying to use the polymorphic this type to do this, but that doesn't work if your plan is to annotate a variable as Operation, since then this is just Operation and you'll just get Context.

Instead, you'll need to make it generic in the type of steps, as shown here:

interface Operation<T extends ExecutableArray> {
    buildContext(): IntersectContextsOf<T>;
    steps: T;
}

And then any variable of that type needs to have the type argument T specified:

const op: Operation<[Executable<RedContext>, Executable<BlueContext>]> = {
    buildContext() {
        return {
            getRed() { return "red" },
            getBlue() { return "blue" }
        }
    },
    steps: [red, blue]
}

It's not pleasant to have to write out those types; unfortunately there's no direct way to ask the compiler to infer T from the initializer, like const op: Operation<infer> = { ⋯ }. There's an open feature request for this at microsoft/TypeScript#32794, but for now it's not part of the language. Generic type arguments are currently only inferred when calling generic functions.

So until and unless that is implemented, you can work around this by using a generic helper function asOperation() and write const op = asOperation({ ⋯ }); instead of const op: Operation = { ⋯ };, which isn't really much different in terms of effort.

Like this:

const asOperation =
    <T extends ExecutableArray>(o: Operation<T>) => o;

Let's try it:

const op = asOperation({
    buildContext() {
        return {
            getRed() { return 'red' },
            getBlue() { return 'blue' },
        }
    },
    steps: [blue, red],
});

const badOperation = asOperation({
    buildContext() { // error! 
        return {
            getRed() { return 'red' },
            getGreen() { return 'green' },
        }
    },
    // Type '() => { getRed(): string; getGreen(): string; }' is 
    // not assignable to type '() => RedContext & BlueContext'.
    steps: [blue, red],
});

Looks good. The compiler infers the right types for your variables, and complains if steps and buildContext() don't agree.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • This is working well as long as I don't but anything in the Context interface. When I add a method in Context, IntersectContextOf returns never. [playground link](https://shorturl.at/fpET3) – Bambou May 22 '23 at 07:35
  • There's a lot of errors in that link, because your `RedContext` and `BlueContext` no longer extend `Context`; and if I fix that, [the problems go away](https://tsplay.dev/m3voEN). If you continue to have issues you might want to open a new post, since followup questions are out of scope here. Good luck! – jcalz May 22 '23 at 12:44