Repository navigation
Generic models #239
Copy link
Copy link
Closed
Labels
🍗 enhancementNew feature or requestNew feature or request🎈 releasedA fix that should close the issue has been releasedA fix that should close the issue has been released👨💻 has PRA PR that potentially fixes the issue existsA PR that potentially fixes the issue exists📑 merged to masterFeature done and merged to masterFeature done and merged to master
Description
I've been working around the limitations of generic models for a while, but I keep arriving at the same obstacles. I'm wondering whether there is any way to further improve generic models, so creating model classes is closer to what I would write in plain Typescript independent of mobx-keystone.
The main problem is that I can't declare a generic model class with generic props:
class M<V extends number | string | boolean> extends Model({
value: prop<V>() // TS ERROR
}) {}Current solutions:
- Use a model class factory and create model classes for all the generic types. This can lead to combinatorial explosion when composing models because I need to create as many models as I have combinations of generic types.
- Use an abstract base class where generic props are plain abstract class properties, inherit from the base class and make the abstract class properties real model props with concrete types. This also leads to combinatorial explosion when composing models.
With runtime types, it seems the equivalent of generics is a factory, so there may not be a solution in that case. But when using prop<T>() there are no runtime types and I'm wondering whether there is a way to get better support for generic models in that case.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
🍗 enhancementNew feature or requestNew feature or request🎈 releasedA fix that should close the issue has been releasedA fix that should close the issue has been released👨💻 has PRA PR that potentially fixes the issue existsA PR that potentially fixes the issue exists📑 merged to masterFeature done and merged to masterFeature done and merged to master
Activity
xaviergonz commentedon Apr 28, 2021
A wild idea is that you could cache the "generic" class and reuse it
The downside is that you'd create a new class per "generic", but it should be quickly garbage collected
sisp commentedon Apr 30, 2021
Thanks for this interesting idea! 🙂
I'm trying to avoid materializing actual classes (or just differently typed aliases) for different "generics". Perhaps a slightly extended example makes my struggles clearer. This is a simplified example of what I'd like to achieve:
Here,
M<T>is a generic model (a container if you will) and the modelsAandBall have a prop of typeMbut with different constraints on the genericT. This is purely a matter of typing, the transpiled JS code is identical. I would like to make sure that when I'm accessing the prop of any instance ofAorBI have proper static type-checking (and IntelliSense in VS Code) about the type ofvalueofM.Currently, it seems I need to create a class for each variant of
M(or an alias with your idea above), e.g.:Or with your idea which results in only one registered model "M":
Perhaps this could be a workaround:
The pattern of this workaround would be:
Model({})(no model props specified yet).ExtendedModel(so still no model props are specified).ExtendedModel(modelClass<...>(...), { ... })and specify all model props there with the correct concrete types which leads to several registered models, orinstanceofchecks aren't easily done because there are only specific classes available (although they are all identical, so actually it's just one).sisp commentedon Apr 30, 2021
Your TS proposal microsoft/TypeScript#36406 would solve this problem more elegantly, but there has been no activity.
sisp commentedon May 1, 2021
Maybe something like this:
xaviergonz commentedon May 1, 2021
Nice, that way you don't even create new classes :)
Btw, you shouldn't need
modelClass<BaseM<T>>(BaseM)but justBaseMin this casesisp commentedon May 1, 2021
True, I've updated the snippet above.
Do you think this could be an "officially recommended" pattern for
mobx-keystone? And do you think it's worth adding thecreateModelfunction to the library? Not sure ifmodelType(and thus decorating the class) should be optional, i.e. whether this function makes much sense when it returns an undecorated model class.xaviergonz commentedon May 1, 2021
I'm not sure. On the one hand it is useful for people who rely on TS for type checking, but on the other hand it introduces a separation between type checked and untype checked patterns and also I don't know how widespread is the case where you need many generics forms from a safe base.
About decorating the class inside the function, I think it makes sense (better be reminded of the decorator when calling the function than forgetting about it and seeing it error in runtime).
sisp commentedon May 1, 2021
Hm, in general would you say that generic models are rather an anti-pattern because they don't work with runtime type-checking anyway? Should factories be preferred over generics?
sisp commentedon May 1, 2021
Regarding real generic models, if there was a way to dynamically create a class with generics, I think we could do this:
Here,
Model(<U>() => ({ value: prop<U>() }))would have to return a class with the genericUand then the genericTfrom the child class gets passed to the dynamically created base class. But I'm not sure if that's possible with Typescript.sisp commentedon May 2, 2021
Check this out:
TS Playground
Might be related to microsoft/TypeScript#30215.
I think it makes a lot of sense that something like
does not work because
Model({ value: prop<T>() })can be called outside the context of subclassing, e.g.so when we want a generic model,
Model(...)needs to return a generic class. SinceModelis all about defining model props (and setting up lots of internal things of course), we need to be able to define generic model props and make sure the returned class has those generics, too.What do you think about this idea?
xaviergonz commentedon May 3, 2021
Nice idea. I got it working for Model, but unfortunately I didn't manage to get it working for ExtendedModel so far when the class being extended is a generic one (since the parameter for extended model is the class itself rather than the class type, which is the one that includes generics).
I'll keep experimenting (unless you think a generic base Model is good enough)
sisp commentedon May 3, 2021
Awesome! 🙂
What would your
ExtendedModel(...)call look like with generics? I was thinking about something like:We need both the base class and the new props to be able to access the same generics, so both must be specified in the same generic arrow function. With
modelClass<Parent<T>>(Parent)I think the correct type should be specified.xaviergonz commentedon May 3, 2021
Good call! I got this working in the end
just double checking, would that be missing something?
sisp commentedon May 4, 2021
Looks amazing, exactly what I've been looking for! 😄
sisp commentedon May 4, 2021
@xaviergonz I'm really curious about your personal opinion and experience:
Do you always use runtime type-checking? If so, why? And if not, why? Or when do you use the one or the other?
I think runtime types for specifying model props were popularized by MST (correct me if I'm wrong). I totally see that the chosen approach allows for deriving TS types automatically, so there is a single source of truth for both runtime and static types. But this pattern also requires using factories while when static type-checking is enough, generic models (especially with your PR) are sufficient, so factories are not needed (which is more efficient). I've been thinking whether runtime type-checking should be strictly an extension of only statically-typed models, so runtime types just come on top (somewhat related to #160).
xaviergonz commentedon May 4, 2021
I don't really favor one or the other. In my case I use typed props since I moved from a MST project and it was easier to port that way.
For a new plan JS project I'd probably use typed props.
For a new TS project I'd probably use untyped props for stuff that doesn't come from backend and typed for stuff that comes from backend.
But that's just me :)
sisp commentedon May 4, 2021
Thanks for sharing your thoughts! About using typed props when data comes from a backend: I assume your
mobx-keystonemodel then has the same shape as the data coming from the backend and the runtime checks are for validation at the runtime boundary. What if the backend data shape differs from what would be a natural state representation inmobx-keystone? Would you decode the backend data using the runtime type-checkers offered bymobx-keystoneoutside and independent of a model and map this validated backend data tomobx-keystonemodels (i.e. use the type-checkers withouttProps)? I know it's getting slightly off-topic, but I'm trying to figure out the real purpose of typed props especially in a TS project.xaviergonz commentedon May 4, 2021
Yes, sometimes they even share a common model (if the backend uses node js).
I never face that issue since they always match 1:1 right now, but I guess it depends. If the backend and frontend are managed by different teams I'd type check them somehow in runtime for sure (just to make sure the contract doesn't get broken). If they are both managed by a same team and they both use a same common interface / MST model then probably it is not worth the trouble.
Btw, out in v0.58.0 :)
sisp commentedon May 5, 2021
I see. Regarding separation of concerns, would you agree that it's sufficient to use only statically-typed models and when data from an external system is converted to model instances, runtime validation is performed to ensure the contract because the shape of that data is not known statically. Once the data has been validated, it is statically typed and model instances can be safely created. What I'm trying to say is: Validation at runtime boundaries seems to be a step that is independent of instantiating models and mutating those instances. With typed props, runtime checks are performed also when model actions are run, but at least in a properly typed TS project there are static guarantees, so is there actually any need to perform runtime checks when running model actions? It feels like validation at runtime boundaries is a separate step and should not be mixed with mutating model instances. Or am I missing something?
sisp commentedon May 8, 2021
Closing since the actual issue was resolved in #242 and 9be6e0f and released with v0.58.{0,2}.
xaviergonz commentedon May 8, 2021
Yeah, I guess you are right. but it is always nice that if you have to validate the data you don't have to use a different library with a separate model to do it so you can keep them easily in sync.
sisp commentedon May 8, 2021
Yeah, I can understand that. Thanks for sharing your thoughts. I'm still trying to get a clear picture of how runtime checks (especially validation, i.e. not bailing out at the first error) can be integrated in an intuitive and general way, and handling generic models is one piece of the puzzle. I think I've made some progress, but that's a discussion to be continued in #160. 😉