Reputation: 53
I'm writing a library where the user may define subclasses of the abstract class State
. Any subclass of State
will "rely" (within the logic of the library) on a number of classes inherited from the abstract class Component
. These dependencies must be declared in the code statically so my library can analyze them up-front. I'd like to also typecheck the methods of the State
-inherited user classes based on the dependencies they define. Here is an example:
class ComponentA extends Component {}
class ComponentB extends Component {}
class ExampleState extends State {
static dependencies = [ComponentA, ComponentB] as const;
// this method should correctly type the tuple provided to this
// user-defined method as containing an instance of ComponentA
// in position 0 and an instance of ComponentB in position 1, ordered as
// provided in the static dependencies property.
initialize(components) {
const [a, b] = components;
console.assert(a instanceof ComponentA); // true
}
}
Specifically, I would like to avoid forcing the user to also pass a generic to State
like State<[typeof ComponentA, typeof ComponentB]>
which would be both verbose and redundant. However, I cannot rely only on the generic instead, because I need the dependency information at runtime.
Are there any options for defining this in TypeScript? Or are statics simply too divorced from instances to be able to share this type information? Is there another pattern (perhaps besides classes) which would be able to accomplish the right developer ergonomics for this usage?
After a lot of experimentation, I have discovered at least one possible usage which maintains ergonomics (type hints on the initialize
method according to the specified dependencies) without requiring redundancy in the code:
However, the usage is still a little awkward. The user must supply a function to create their pseudo-class object, and utilize a closure to store "instance properties" which they normally would simply attach to their subclass.
I'm still interested to see if someone can come up with a more streamlined solution, but if not I'll self-answer with at least this, which works.
I'm working on a framework library for creating web games which is inspired by / extending the ECS pattern for game development. The piece I want to add to ECS is a way for framework users or complementary library authors to define declarative state management based on certain combinations of Components being attached to any particular Entity.
As a user writing a game with the framework, I want to enable a pattern like "If any Entity has the Components [Image, Position, Color]
attached to it at any point in the game's lifecycle, a Sprite
"State" object should be constructed by the framework which derives its data from those Components."
To accomplish this, the user will provide State definitions to the framework which implement an initialize
/destroy
interface. These State definitions will be responsible for providing the imperative logic to enable declarative usage in the game's logic code.
Here's a sketch of usage I'd ideally want to enable, using a hypothetical SomeSpriteThing
resource analogous to something from PixiJS or ThreeJS, for example:
class SpriteState extends State {
static dependencies = [Image, Position, Color] as const;
private sprite = new SomeSpriteThing();
initialize([image, position, color]) {
this.sprite.src = image.src;
this.sprite.x = position.x;
this.sprite.y = position.y;
this.sprite.tint = color.value;
this.sprite.load();
}
destroy() {
this.sprite.unload();
}
}
A library user would provide such State classes to the framework, and whenever an Entity is assigned [Image, Position, Color]
components together, a SpriteState
will be generated by the framework and made available to game logic code.
However, in the ideal usage above, initialize
would have no way of inferring the typings of the provided tuple from the dependencies
static.
As a Typescript-focused library, I want the code which users write to be thoroughly typechecked without excessive effort on their part in specifying redundant typings. That's why I'd like to avoid forcing the user to provide the same list of dependency types to a generic parameter in the State
superclass - not only is it inconvenient, but there is no way to enforce that the dependencies
and the generic remain in sync, as one is only visible to code and the other to typechecking.
I recognize this is not a pattern I've seen before and is possibly quite over-engineered. Perhaps that's all there is to it. But I was curious if there was some possible way of approaching this with type-safety.
Upvotes: 1
Views: 1104
Reputation: 42248
Are there any options for defining this in TypeScript? Or are statics simply too divorced from instances to be able to share this type information?
When you create a class ExampleState
you also create a type ExampleState
which describes an instance of that class. The static properties do not apply to the ExampleState
since they are not instance properties. Each class also has a second type, typeof ExampleState
or new () => ExampleState
, which describes the constructor of the class. This is where the static properties are defined. (relevant docs section)
Is there another pattern (perhaps besides classes) which would be able to accomplish the right developer ergonomics for this usage?
My recommendation is to use some sort of higher-order function to create your classes. It's hard for me to get this exactly right because I don't fully understand the use case, but this should point you in the right direction at least.
This interface describes the constructor that we want. It can be instantiated to create a State<Deps>
and it also has a property dependencies
of type Deps
.
interface StateCreator<Deps extends ComponentType<any>[]> {
new (): State<Deps>;
dependencies: Deps;
}
This function takes your dependencies and returns an anonymous class which has those dependencies as static properties.
function makeState<Deps extends ComponentType<any>[]>(dependencies: Deps): StateCreator<Deps> {
return class extends State<Deps> {
static dependencies: Deps = dependencies;
initialize(components: Deps) {
//do something
}
}
}
I'm a bit lost on what your intention is with initialize
. Why would we provide Deps
as an argument if Deps
is already known from the static properties? Did you mean for this to take instances of ComponentA
and ComponentB
?
Upvotes: 1