typescriptabstract-classtypescript-genericsentity-component-system

Middle static Classes without interface in typescript


I am currently exploring an alternative to an approach that previously worked but has some complexities in JavaScript that I would like to avoid.

So, I've reconsidered the TypeScript layer a bit, and I've come up with something very close to what I want.

The only issue is, I have an error that I can't get rid of without using ts-ignore.

Does anyone know how I could resolve it without breaking the following design?

type Constructor<T> = new ( ...args: never ) => T;
type ComponentType<T extends Component=Component> = Constructor<T>


// components
abstract class Component {}
class ComponentA extends Component { #id = Symbol() }
class ComponentB extends Component { #id = Symbol() }
class ComponentC extends Component { #id = Symbol() }

// systems
abstract class System<R extends ComponentType[]=ComponentType[]> {
    static config<
        const R extends ComponentType[],
    >(
        config: R
    ) {
        abstract class __System<RR extends ComponentType[]=R> extends this<RR|R> {}
        return __System;
    }

    declare test1: R;
    declare test2: R[number];
    declare test3: <T extends R[number]>() => T;

    public update() {
        this.test1;
    }
}

//🔴 try adding any "middleware" class between System and final systemA
//// @ts-ignore - this is not really a solution to use  @ts-ignore
abstract class SystemGameMiddle<R extends ComponentType[]=ComponentType[]> extends System.config(
    [ComponentA] // this middleware will intercept constructor to also add and make avaible ComponentA
)<R> {
    static override config<
        const R extends ComponentType[],
    >(
        config: R
    ) {
        abstract class _SystemGameMiddle<RR extends ComponentType[]=R> extends this<RR|R> {}

        // this will work for ts side, but add complexity on js side because need add static field to keep track of the original config and we also lost the extends of SystemGameMiddle
        // abstract class _SystemGameMiddle<RR extends ComponentType[]=R> extends super.config( config )<RR|R> {}
        return _SystemGameMiddle;
    }

    override update() {
        this.test1;
        this.test2;
        this.test3();
    }
}

// for the hight level, i hould not have typescript complexity, ts should be almost automaticly deduce the type with ths js side injected in static class field
export class System1 extends SystemGameMiddle.config(
    [ComponentB]
) {
}

export class System2 extends SystemGameMiddle.config(
    [ComponentB, ComponentC]
) {
}

// TEST:::
// js: proto should be (System1 => _SystemGameMiddle => SystemGameMiddle => __System => System)
declare const system1:System1;
system1.test1; //ts: expected: [] | [typeof ComponentA] | [typeof ComponentB]
system1.test2; //ts: expected:  typeof ComponentA | typeof ComponentB
system1.test3(); //ts: expected:  typeof ComponentA | typeof ComponentB
// js: proto should be (System2 => _SystemGameMiddle => SystemGameMiddle => __System => System)
declare const system2:System2;
system2.test1; // ts: expected:  [] | [typeof ComponentA] | [typeof ComponentB, typeof ComponentC]
system2.test2; //ts: expected:  typeof ComponentA | typeof ComponentB | typeof ComponentC
system2.test3(); //ts: expected:  typeof ComponentA | typeof ComponentB | typeof ComponentC

type SystemWith<
    R extends ComponentType,
> = System & { test3<T extends R>( ): T}; // with implements

function useSystem1( system:System ) {
    system.test1;
    system.test3;
}
function useSystem2( system:SystemGameMiddle ) {
    system.test1;
    system.test3;
}
function useSystem3( system:SystemWith<typeof ComponentB> ) {
    system.test1;
    system.test3();
}
useSystem1( system1 );
useSystem2( system1 );
useSystem3( system1 );

Playground


Solution

  • The main problem I'm seeing is that you are expecting a bit too much of TypeScript's inference abilities. Inside the static config() method you return a locally declared class named __System. You want that that class to have a strong type that keeps track of the R type argument. But it doesn't seem to do so. It's hard to tell what it actually is, since the IntelliSense will show you typeof __System, which doesn't give any information.

    The first step I would take here is to explicitly write out the types you want things to be and see if the compiler can verify that your values conform to them, instead of just hoping that they will be inferred that way. Then if things still don't work, at least you can see where they go wrong.

    To that end, I'd say that the __System class is an abstract class constructor type which constructs System instances, and which has its own config method. By inspection of your code, it looks like this:

    type SystemCtor<RR extends ComponentType[]> = (
        abstract new <R extends ComponentType[]>() => System<R | RR>
    ) & {
        config<R extends ComponentType[]>(config: R): SystemCtor<R | RR>
    };
    

    Notice how SystemCtor<RR> is recursively defined, and how each call to config() will incorporate R and RR together in a union. Also I had to make it an intersection because there's no way to use abstract new inside of the same object type as config (see this comment on microsoft/TypeScript#36392).

    And now, by inspection, it seems like we want config() to return a SystemCtor<R>, so let's annotate it as such:

    abstract class System<R extends ComponentType[] = ComponentType[]> {
        static config<const R extends ComponentType[]>(
            config: R
        ): SystemCtor<R> { // <-- annotated the return type
            abstract class __System<RR extends ComponentType[] = R> extends this<RR | R> { }
            return __System;
        }
    
    }
    

    And that compiles without error. So while the compiler didn't infer that dependence on R, it's happy enough to allow it to be annotated.

    Once we do that things more or less just start working for you:

    declare const system1: System1;
    system1.test1; // [typeof ComponentA] | [typeof ComponentB]
    system1.test2; // typeof ComponentA | typeof ComponentB
    system1.test3(); // typeof ComponentA | typeof ComponentB
    
    declare const system2: System2;
    system2.test1; // [typeof ComponentA] | [typeof ComponentB, typeof ComponentC]
    system2.test2; // typeof ComponentA | typeof ComponentB | typeof ComponentC
    system2.test3(); // typeof ComponentA | typeof ComponentB | typeof ComponentC
    

    This may or may not be enough for your particular use case, but the point is that you should take more explicit control over the types if the inferred ones don't meet your needs. Yes, this can be tedious, since you'll end up writing things that are at least conceptually redundant, but it has the advantage of helping express your actual intent and pinpointing problems.

    Playground link to code