I'm trying to figure out Entity Component System and for usability I need to be able to pass an array of different Component definitions and get a merged type as a result.
I'm trying to do this:
const Position = defineComponent({ x: 0, y: 0 });
const Container = defineComponent({ capacity: 0, contents: new Array<{ item: number; amount: number }>() });
world.withComponents(Position, Container).forEach(([id, entity]) => {
console.log(entity.x);
consone.log(entity.capacity);
});
Full code:
interface ISchema {}
type SimpleType = number | string | boolean;
type Type<V> = V extends SimpleType
? V
: V extends Array<infer RT>
? Array<Type<RT>>
: V extends ISchema
? ComponentType<V>
: never;
type ComponentType<T extends ISchema> = {
[key in keyof T]: Type<T[key]>;
};
type ComponentDefinition<T extends ISchema> = {
id: Symbol;
type: ComponentType<T>;
};
function defineComponent<T extends ISchema>(schema: T): ComponentDefinition<T> {
return {
id: Symbol(),
type: schema as ComponentType<T>,
};
}
type TupleToIntersection<T extends any[]> = {
[I in keyof T]: (x: T[I]) => void;
}[number] extends (x: infer I) => void
? I
: never;
class World
{
protected readonly entities = new Map<number, { data: ISchema; components: Set<Symbol> }>();
withComponents<T extends ComponentType<ISchema>>(
definitions: ComponentDefinition<T>[]
): Array<[number, TupleToIntersection<T[]>]> {
const result: Array<[number, TupleToIntersection<T[]>]> = [];
for (const [id, entity] of this.entities) {
let isMatch = true;
for (const def of definitions) {
if (!entity.components.has(def.id)) {
isMatch = false;
break;
}
}
if (isMatch) {
result.push([id, entity.data as TupleToIntersection<T[]>]);
}
}
return result;
}
}
Currently I getting the following error on this line:
world.withComponents([Position, Container])
Argument of type 'ComponentDefinition<{ capacity: number; contents: { item: number; amount: number; }[]; }>' is not assignable to parameter of type 'ComponentDefinition<{ x: number; y: number; }>'.
Types of property 'type' are incompatible.
Type 'ComponentType<{ capacity: number; contents: { item: number; amount: number; }[]; }>' is missing the following properties from type 'ComponentType<{ x: number; y: number; }>': x, yts(2345)
So my problem is that I'm not able to figure out how to accept an array of different generics.
You can make withComponents()
generic in the tuple type T
of arguments to ComponentDefinition<>
for each element of the definitions
input. That is, if definitions
is of type [ComponentDefinition<X>, ComponentDefinition<Y>, ComponentDefinition<Z>]
, then T
would be [X, Y, Z]
. Then you can make the type of definitions
be a mapped type over the tuple T
, and the compiler can infer T
from definitions
because it's a homomorphic mapped type (see What does "homomorphic mapped type" mean?). Like this:
withComponents<T extends ComponentType<ISchema>[]>(
definitions: [...{ [I in keyof T]: ComponentDefinition<T[I]> }]
): Array<[number, TupleToIntersection<T>]>
So { [I in keyof T]: ComponentDefinition<T[I]> }
is the relevant mapped type. And I've wrapped it in a variadic tuple type [...
+]
, which, as the implementing pull request microsoft/TypeScript#39094 mentions, "can conveniently be used to indicate a preference for inference of tuple types".
And everywhere else in your code that uses T
will need to be updated to account for the fact that it's [X, Y, Z]
and not X | Y | Z
... that is, every TupleToIntersection<T[]>
should be replaced with TupleToIntersection<T>
.
Let's try it out:
const Position = defineComponent({ x: 0, y: 0 });
const Container = defineComponent({ capacity: 0, contents: new Array<{ item: number; amount: number }>() });
const world = new World();
world.withComponents([Position, Container]).forEach(([id, entity]) => {
console.log(entity.x);
console.log(entity.capacity);
});
Looks good!