Bambou
Bambou

Reputation: 139

How can I infer the interface of an object that is the intersection of all first parameter of a specific method in an array of object?

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

Upvotes: 1

Views: 66

Answers (1)

jcalz
jcalz

Reputation: 329903

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

Upvotes: 0

Related Questions