I have aligned my approach with my nearest current architecture, striving to minimize complexity as much as possible.
I have aimed for the closest approximation to a satisfactory result, but I encountered a single failed test with this approach.
I'm on the verge of giving up, as it has already been three days! However, if your expertise can uncover a solution that ensures the success of all tests, it would be truly amazing!
the getComponentFromEntity
is maybe the key, maybe need a black magic type pattern to solve this ?!
Thank you for your valuable time
//utils types
type Constructor<T> = { new(...args: any): T };
type ExtractComponentType<T> = T extends Entity<infer C> ? C : never;
type EntitiesCareMap<C extends Component> = Map<number, Entity<C>>
type ComponentType<T extends Component = Component> = Constructor<T>;
type TupleToInstances3<T extends readonly unknown[]> = {
[K in keyof T]: T[K] extends Constructor<infer U> ? U extends {} ? U : never : never;
}
type ExtractSystemComponents4<
S extends Rules,
K extends RULES
> = S[K] extends ComponentType[] ? S[K] extends never[] ? UnknowComponent : TupleToInstances3<S[K]>[number] : never;
interface SystemUpdate<S extends System = System, R extends Rules = S['rules']> {
entities: EntitiesCareMap<
ExtractSystemComponents4<R, RULES.hasAll>
>;
}
// issue
class Entity<
C extends Component = Component,
> {
declare public components: Set<C>;
get<T extends C>(componentClass: Constructor<T>): T {
return undefined as unknown as T;
}
has<T extends Component>( componentClass: Constructor<T> ): this is Entity<T> {
return false;
}
}
abstract class Component {
foo() { }
}
enum RULES {
hasAll,
}
type Rules = { readonly [K in RULES]?: ComponentType[] };
abstract class System {
abstract rules: Rules;
abstract onUpdate(t: SystemUpdate<System, Rules>): void;
}
export class UnknowComponent extends Component {
#component!: never;
}
export class AComponent extends Component {
#component!: never;
}
export class BComponent extends Component {
#component!: never;
}
export class CComponent extends Component {
#component!: never;
}
export class DComponent extends Component {
#component!: never;
}
class SystemA extends System {
public rules = {
[RULES.hasAll]: [AComponent, BComponent],
};
onUpdate({entities}: SystemUpdate<SystemA>) {
entities.forEach(( e ) => {
e.get(BComponent)// π’ this should pass.
e.get(AComponent)// π’ this should pass.
e.get(CComponent)// π΄ this should error
if (e.has(CComponent)) {
e.get(CComponent)// π’ this should pass.
e.get(DComponent)// π΄ this should error
if (e.has(DComponent)) {
e.get(DComponent)// π’ this should pass.
}
}
});
}
}
declare const ab: Entity<BComponent> | Entity<BComponent | CComponent>;
/** Get a components from entity */
function getComponentFromEntity<E extends Entity, C extends ExtractComponentType<E>>(entity: E, component: Constructor<C>): C {
return entity.get(component);
}
getComponentFromEntity(ab, BComponent) // π’ this should pass.
getComponentFromEntity(ab, AComponent) // π΄ this should error.
getComponentFromEntity(ab, CComponent) // π΄ this should error.
//^?
declare const a: Entity<BComponent | CComponent>;
a.get(BComponent)// π’ this should pass.
a.get(AComponent)// π΄ this should error
I'd say that you want ExtractComponentType<T>
to turn unions in T
to intersections in the output type. So ExtractComponentType<A | B>
will be equivalent to ExtractComponentType<A> & ExtractComponentType<B>
. (That is, you want to distribute your operation over unions in T
but in a contravariant way (see Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript for more info on variance).
That's because when you call getComponentFromEntity(e, c)
, if c
is of type Entity<A | B>
then c
can be either A
or B
(because Entity<A | B>
accepts either), but if c
is of type Entity<A> | Entity<B>
then you don't know which it accepts, so c
has to be both A
and B
for that to be safe.
So let's implement it.
Here's one way:
type ExtractComponentType<T> =
(T extends Entity<infer C> ? ((x: C) => void) : never) extends
(x: infer I) => void ? I : never;
type X = ExtractComponentType<Entity<BComponent | CComponent>>;
// type X = BComponent | CComponent
type Y = ExtractComponentType<Entity<BComponent> | Entity<CComponent>>;
// type Y = BComponent & CComponent
You can see that it works as intended. The implementation uses a contravariance trick with conditional types, as described in Transform union type to intersection type. Since function types are contravariant in their parameter types, we move the type into a function parameter position before inferring from it.
We're 95% of the way there. Here's the rest:
function getComponentFromEntity<
E extends Entity,
C extends Component & ExtractComponentType<E>
>(entity: E, component: Constructor<C>): C {
return entity.get(component);
}
All I had to do there is tell the compiler that C
would definitely be a Component
of some sort, to prevent the implementation from complaining. TS can't really do higher order reasoning about generic conditional types, so even though ExtractComponentType<E>
must be compatible with Component
by construction, the compiler fails to see it. So I added Component &
to fix that.
Let's test it:
declare const ab: Entity<BComponent> | Entity<BComponent | CComponent>;
getComponentFromEntity(ab, BComponent) // π’ okay
getComponentFromEntity(ab, AComponent) // π΄ error!
getComponentFromEntity(ab, CComponent) // π΄ error!
declare const a: Entity<BComponent | CComponent>;
a.get(BComponent)// π’ okay
a.get(AComponent)// π΄ error!
Looks like the behavior you wanted!