Reputation: 139
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
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.
Upvotes: 0