typescripttypescript-typingstypescript-genericsentity-component-system

TypeScript: accept an array of different generics and return a merged type


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.


Solution

  • 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!

    Playground link to code