-
Notifications
You must be signed in to change notification settings - Fork 25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
First-class support for comprehensive runtime validation #160
Comments
Thanks for the proposal. I'll take a look at it this weekend. |
Let me add a couple of thoughts.
I think errors and their relationships could be represented along the lines of this: enum TypeCheckKind {
Error,
And,
Or
}
interface TypeCheckError {
readonly kind: TypeCheckKind.Error
readonly path: Path
readonly expectedTypeName: string
readonly actualValue: any
readonly custom: any // How to properly deal with custom error data in a type-safe way?
}
interface TypeCheckExpression {
readonly kind: TypeCheckKind.And | TypeCheckKind.Or
readonly args: ReadonlyArray<TypeCheckError | TypeCheckExpression>
} Most error expressions have the
Since parent models can impose additional constraints on their children, the complete set of errors is only available at the root of the state tree in the general case. But I'd like each model to have access to its complete set of errors (including the errors of its children), some of which may be coming from a parent model. Perhaps a context could be used: const typeCheckContext = createContext<TypeCheckError | TypeCheckExpression | undefined>() Whenever a model is in the context of onInit() {
onChildAttachedTo(
() => this,
child => {
// not sure if there is a more efficient way of only reacting to attachment/detachment
// of only the nearest children
if (!isNearestParentOf(this, child)) { // imagine this function existed
return
}
const path = getParentToChildPath(this, child)!
typeCheckContext.setComputed(child, () => {
// filter all errors whose paths begin with `path` and remove this prefix
//
// `filterTypeCheckErrors` is assumed to be tolerant of `undefined`, i.e.
// `filterTypeCheckErrors(undefined, ...) -> undefined`
return filterTypeCheckErrors(this.$errors, { // See below about `$errors`
path,
prefix: true, // `true` when `path` is a prefix, `false` when `path` is an exact match
strip: true, // `true` when `path` should be left-stripped from the matching errors
})
)
return () => typeCheckContext.unset(child)
},
{ deep: true, fireForCurrentChildren: true }
)
}
Perhaps like this: @computed.struct
get $errors(): TypeCheckError | TypeCheckExpression | undefined {
return typeCheckContext.getProviderNode(this)
? typeCheckContext.get(this)
: this.typeCheck() // I guess ...?
} |
On second thought, providing the errors without onInit() {
// set the error context:
// (a) if the context does not exist or this node provides the context,
// perform error checking and provide the result
// (b) if the context exists and is provided by another node, just pass
// it along
typeCheckContext.setComputed(this, () => {
const root = typeCheckContext.getProviderNode(this)
return !root || root === this
? this.typeCheck() // I guess ...?
: typeCheckContext.get(this)
})
}
@computed.struct
get $errors(): TypeCheckError | TypeCheckExpression | undefined {
// the provider node cannot be undefined because it's either this node
// or a parent node
const root = typeCheckContext.getProviderNode(this)!
const path = getParentToChildPath(root, this)!
return filterTypeCheckErrors(typeCheckContext.get(this), {
path,
prefix: true,
strip: true,
})
} Here, again only the root model performs the type-checking for the entire tree and provides the errors via the context, but now each child model simply extracts the errors related to itself and its subtree using the path from the root to itself instead of using the path from its nearest parent to itself. It is a bit redundant that each model sets the context in I believe that the current runtime types perform caching, so changing one value in the state tree would not necessarily lead to type-checking the entire tree from scratch. This makes type-checking just at the root of the tree efficient. |
I've been thinking about this a bit and with refinement types it seems like it's so close to being there, but the concept, allowing a value to exist with errors, seems to have a nasty way of "poisoning" the tree for lack of a better term. What I mean is once it's anywhere it kind of seems like it has to be everywhere. For something to be really pervasively added, I think idea backing it needs to be fundementally sound/useful otherwise it would eat away at a purity that mobx-keystone feels like it has at the moment. I think I would personally like to see it added as part of
Not sure if this is possible, but I would then expect something of a custom cloning that "unrefines" all the refinement types in the original back to their base types, allowing the draft to contain the invalid, malformed, work-in-progress data. Your
I think Drafts being a one-stop-shop for wiring up anything form-y or input-y feels right, at least to me. |
I like the idea of making comprehensive runtime validation a feature of I agree that the extended draft interface should follow the original one, but I think an array of interface Draft {
...
isValid: boolean
isValidByPath(path: Path): boolean
errors: TypeCheckError | TypeCheckExpression | undefined;
errorsByPath(path: Path): TypeCheckError | TypeCheckExpression | undefined
} I noticed another requirement that the runtime validation should satisfy in my opinion. While In addition, what happens if I need to refine the model type, e.g. in order to add a constraint that checks the combination of several props? The current runtime type system lets me refine |
After thinking about it some more, I think that class models mix model prop definition and runtime type-checking in an unnecessarily strict way. Let me elaborate a bit more. A class model is created by inheriting from the class created by class MyModel extends Model(/* which object props are model props? */, /* runtime type */) { ... } For example: class MyModel extends Model({
kind: prop(),
value: prop()
},
types.or(
types.object(() => ({
kind: types.literal('float'),
value: types.number
})),
types.object(() => ({
kind: types.literal('int'),
value: types.integer
}))
)) { ... } The runtime type cannot be arbitrary of course. It must be a Opinions? |
First of all, sorry it took me this long to respond. I tend to agree with @Sladav in that "application state" modelling and "user input" state are not exactly the same (although usually the user input is a less strict version of the application one). In the first case it would be a hard constraint, something that if were to ever break would make the application state invalid. That can be already covered via throwing / returning an error before the state becomes invalid (e.g., throwing when a user with an already existing username is about to be added to the list of users), or a refinement type (which is basically a throw error check too). For the second case (user input state) it is true that mobx-keystone doesn't directly offer a tool for this, but maybe in this case just following a pattern is enough to solve it? For example, if the programmer followed a pattern like:
interface BaseError {
path: Path // path to the child in which the error(s) is located
}
interface MyError extends BaseError {
error: string; // might be a string that might be translated, already translated, only English, a code...
}
@computed
get $errors(): Array<MyError> {
const errors: MyError[] = []
// local errors
if (this.name.length > 30) errors.push("name too long", ["name"])
// children errors, might be done custom (member by member) or by auto aggregation of children
getChildrenObjects(this, { deep: true }).forEach(child => {
const childErrors = child.$errors;
if (childErrors) {
childErrors.forEach(childError => {
errors.push({
...childError,
path: [ getParentPath(child).path, ...childError.path ]
});
});
errors.push(...childErrors);
}
})
return errors;
} It is true however that one way to make this pattern easier would be to wrap the code from the second part (getChildrenObjects etc) into some kind of "getErrors" function and document the pattern to kind of make it more "official". Also I think this pattern would be easily applicable to drafts. Would that be enough? |
I also agree with @Sladav and you about "application state" vs. "user input state", but I had only seen it this way for standard user input forms although the idea, of course, extends to more complex editing, too. Unless I'm missing something, I believe the "user input state" is always a relaxed version of the "application state", so as @Sladav suggested for validating "user input state" only the base types should be strictly enforced (an exception is thrown when they are violated) while refinement errors are merely collected for user feedback. I'm not convinced that the pattern you sketched is sufficiently generic though:
Related to the above and in continuation of my thoughts in #160 (comment), I think specifying runtime types independently for each model prop is not sufficient. What if the "application state" had a coupling of props, e.g. according to the following schema? types.or(
types.object(() => ({
kind: types.literal('float'),
value: types.number
})),
types.object(() => ({
kind: types.literal('int'),
value: types.integer
}))
) When expressing How about this: Let's give the @model(
"UnionModel",
types.or(
types.object( // needs to become tolerant of additional properties
() => ({
kind: types.literal("float"),
value: types.number,
})
),
types.object( // needs to become tolerant of additional properties
() => ({
kind: types.literal("int"),
value: types.integer,
})
)
)
)
class UnionModel extends Model({
kind: prop<"float" | "int">(),
value: prop<number>(),
}) {}
@model(
"ComputedPropertyModel",
types.object(() => ({ value: types.number })) // needs to become tolerant of additional properties
)
class ComputedPropertyModel extends Model({}) {
get value(): number {
return 10
}
} I've successfully implemented this feature (to some extent) to see if it works (also in terms of Typescript typings). In my opinion, this feature would be valuable on its own already without the distinction between "application state" and "user input state". But with this more comprehensive type/schema in place, I believe we would be one step closer to supporting error collection for "user input state" (only when type-checking is performed in a draft). |
@sisp @model("Float")
class Float extends Model({
kind: tProp(types.literal("float")),
value: tProp(types.number),
}) {}
@model("Int")
class Int extends Model({
kind: tProp(types.literal("int")),
value: tProp(types.integer),
}) {}
@model("Root")
class Root extends Model({
// choose one or the other
value: tProp(types.or(types.model<Float>(Float), types.model<Int>(Int)))
}) {} or assuming they don't need to be full models const floatType = types.object(() => {
kind: types.literal("float"),
value: types.number,
})
const intType = types.object(() => {
kind: types.literal("int"),
value: types.integer,
})
const floatOrIntType = types.or(floatType, intType)
@model("Root")
class Root extends Model({
value: tProp(floatOrIntType)
}) {} |
@xaviergonz: Yes that's right, although in both cases the prop Let's look at a shopping cart, for instance: const quantityType = types.refinement(types.integer, (n) => n > 0)
const moneyType = types.refinement(types.number, (n) => n >= 0)
@model("ShoppingItem")
class ShoppingItem extends Model({
quantity: tProp(quantityType),
unitPrice: tProp(moneyType),
}) {}
@model("ShoppingCart")
class ShoppingCart extends Model({
items: tProp(types.array(types.model(ShoppingItem))),
budget: tProp(moneyType),
}) {
@computed
get totalPrice() {
return this.items.reduce((total, item) => total + item.quantity * item.unitPrice, 0)
}
} I'd like to be able to check (using the same declarative runtime type system) that const shoppingCartType = types.refinement(
types.object({
items: types.array(types.model(ShoppingItem)),
budget: moneyType,
totalPrice: moneyType
}),
(cart) => cart.totalPrice <= cart.budget,
)
@model("ShoppingCart", shoppingCartType)
class ShoppingCart extends Model({
items: tProp(types.array(types.model(ShoppingItem))),
budget: tProp(moneyType),
}) {
@computed
get totalPrice() {
return this.items.reduce((total, item) => total + item.quantity * item.unitPrice, 0)
}
} Do you see my point? |
If we added a typeCheck() optional method to models (that gets invoked as part of the runtime type checking), would it achieve the same? @model("ShoppingCart")
class ShoppingCart extends Model({
items: types.array(types.model(ShoppingItem)),
budget: moneyType,
}) {
// would get called as part of the runtime validation
typeCheck() {
if (this.totalPrice <= this.budget) {
// TypeCheckError could have a new overload where you can provide an error message instead of type/actualValue
return new TypeCheckError(["totalPrice"], "above budget")
}
}
@computed
get totalPrice() {
return this.items.reduce((total, item) => total + item.quantity * item.unitPrice, 0)
}
} and for non class model types then "refinement" would still be used (although could be enhanced with an optional error message)
Actually you can make that value even a root store: const valueModel = fnModel(floatOrIntType, "FloatOrIntModelName")
const value = valueModel.create({ kind: "float", value: 3.14 })
registerAsRootStore(value) // if needed
// or if no runtime type checking is need
type FloatOrInt = { kind: "float", value: number } | { kind: "int", value: number }
const valueModel = fnModel<FloatOrInt>("FloatOrIntModelName")
const value = valueModel.create({ kind: "float", value: 3.14 })
registerAsRootStore(value) // if needed
// or even just as a plain object
type FloatOrInt = { kind: "float", value: number } | { kind: "int", value: number }
const value: FloatOrInt = toTreeNode({ kind: "float", value: 3.14 })
registerAsRootStore(value) // if needed
typeCheck(floatOrIntType, value) // if desired |
Well, for this particular refinement, it would be the same I guess. But in order to validate a more complex computed property, e.g. an array of back-references, I think it would be nice to use the declarative type system instead of writing imperative validation logic. A workaround may be to declare the type separately and call the Related to the above, I've been having some trouble experimenting with these ideas when trying to type-check a model when using Or how about something like this? @model("ShoppingCart")
class ShoppingCart extends Model(
// TS types are inferred from the runtime types below.
// Default values and transforms can be provided here.
{
items: prop(),
budget: prop(),
},
// Optional runtime types. If omitted entirely or no runtime
// type is available for a model prop above, TS type needs
// to be provided to that `prop` above explicitly.
types.refinement(
types.object({
items: types.array(types.model(ShoppingItem)),
budget: moneyType,
totalPrice: moneyType,
}),
(cart) => cart.totalPrice <= cart.budget
)
) {
@computed
get totalPrice() {
return this.items.reduce((total, item) => total + item.quantity * item.unitPrice, 0)
}
} Model class inheritance would correspond to intersecting the runtime types of the base class and the child class (using a What do you think? I really think that keeping type-checking declarative as much as possible is a good idea. |
The last idea seems to require TS types for abstract class properties derived from a mapped type which I don't think is (currently) possible. When the runtime type is provided as the second argument of the Unless I'm missing something, I'd say that using a decorator to provide the validation runtime type is the best option. |
@xaviergonz Not sure if you've missed my comments in #195 or didn't have time yet or perhaps absolutely dislike that PR, but is there any chance you could check it and give some feedback? I really think this is a great feature and that the implementation integrates nicely with the rest of mobx-keystone. I'm still a bit uncertain whether setting the validation type in the |
mobx-keystone
is opinionated about structuring data in a tree. This strong assumption enables many useful features such as references, snapshots etc. for whichmobx-keystone
has first-class support and which makes it such a great library. In my opinion, one feature is missing though: runtime validation of models which collects all errors rather than throwing an exception at the first encountered error.Validation of user input is a common task in web development. When users enter malformed or otherwise invalid data, it is important to present them with feedback in order to help them correct their mistakes. Libraries such as
mobx-keystone
andmobx-state-tree
offer runtime type checking inspired byio-ts
and its predecessors, which follow the principle of domain-driven design.mobx-keystone
's runtime types are great as guards against invalid data, but an exception is thrown at the first encountered error which means they cannot be used to build a comprehensive feedback system. But I think there is only a small gap between the current runtime types and ones that can collect all errors.To give an example: Let's say a model prop must be an integer in the range 0-10 in order to be valid. Right now, I could create a refinement of the integer type to validate the value range. This prop can be edited by a user using a form field, and let's assume the user enters a non-integer number or an integer outside this range. I wouldn't want the app to throw an exception. Instead, I'd like to get an error message that tells the user to enter an integer in the range 0-10. This could be achieved by enforcing a
number
-typed value (where an exception is thrown if a non-number
value is set) while the semantic constraints (integer in the range 0-10) are validated gracefully.If you (@xaviergonz, and of course also others) are interested in this feature, I'd like to discuss ideas how it could be implemented in
mobx-keystone
.I currently see the following requirements to make this sufficiently generic in order to cover a variety of use cases:
I think there are a couple of design decisions to be made:
model.$errors
similar tomodel.$
with regard to naming)?What do you think? If you're interested in having this feature added to
mobx-keystone
, I already have some experience with the design of some of the parts that I'd be happy to contribute to the discussion.The text was updated successfully, but these errors were encountered: