Open voxbono opened 4 years ago
This feature is much more popular than I initial thought. #220 and #219 are also about this.
@davidchambers posted a proof-of-concept pure function on gitter that provides good error messages in English. The drawbacks being that it's painful to translate the error messages into user facing messages.
As mention in both #219 and #220, there already exist an exposed (though supposedly private) method to validate types on each type. My initial thought was to translate the internal (to sanctuary-def) error message into 3 well-define error messages.
As far as I can tell there is only 5 possible outcome of an validation of any type (unless we bake in interconnected rules, which we will not).
undefined
/null
.Currently we have Type ~> validate -> Env -> a
.
This comment is going to be long so I'm splitting it into several comments...
My first attempts was to create a pure function that is separate from sanctuary-def, so its immediately available to try out in projects without a new release of sanctuary-def. Having well-defined error messages/objects will ease translation of said errors to public facing error messages. E.g. "Start date is not a valid date"/"Start dato er ikke en godkendt dato" or "Start date is missing"/"Start dato mangler" etc.
Example:
const BarFoo = $.NamedRecordType
('BarFoo')
('')
([])
({
foo: $.String,
bar: $.NonEmpty (
$.NullaryType
('DateIso')
('')
([$.String])
(x => {
console.debug (`test ran with x = ${x}`);
return /^\d{4}-[0-1]\d-[0-3]\d$/.test (x);
})
),
fez: $.Number,
});
Examining the return types from Type.validate
, I get the following:
// 1.1
BarFoo.validate ($.env) (null)
// -> Left ({"propPath": [], "value": null})
// 1.2
BarFoo.validate ($.env) (undefined)
// -> Left ({"propPath": [], "value": undefined})
// 2.1
BarFoo.validate ($.env) ({foo: 'blue'})
// -> Left ({"propPath": [], "value": {"foo": "blue"}})
// 2.2
BarFoo.validate ($.env) ({foo: 'blue', bar: '2020-10-10'})
// -> Left ({"propPath": [], "value": {"bar": "2020-10-10", "foo": "blue"}})
// 3.1
BarFoo.validate ($.env) ({foo: 'blue', bar: '2020-10-10', fez: 2, lorem:"zzz"})
// -> Right ({"bar": "2020-10-10", "fez": 2, "foo": "blue", "lorem": "zzz"})
// test ran with x = 2020-10-10
// 3.2
BarFoo.validate ($.env) ({foo: 'blue', bar: '2020-10-10', fez: 2, lorem:"zzz", ipsum: 123})
// -> Right ({"bar": "2020-10-10", "fez": 2, "foo": "blue", "ipsum": 123, "lorem": "zzz"})
// test ran with x = 2020-10-10
// 4.1
BarFoo.validate ($.env) ({foo: 'blue', bar: '2020-29-10', fez: 2})
// -> Left ({"propPath": ["bar", "$1"], "value": "2020-29-10"})
// test ran with x = 2020-29-10
// 4.2
BarFoo.validate ($.env) ({foo: 'blue', bar: '2020-29-10', fez: 'lol'})
// -> Left ({"propPath": [], "value": {"bar": "2020-29-10", "fez": "lol", "foo": "blue"}})
// 5
BarFoo.validate ($.env) ({foo: 'blue', bar: '2020-10-10', fez: 2})
// -> Right ({"bar": "2020-10-10", "fez": 2, "foo": "blue"})
// test ran with x = 2020-10-10
Initially it looks like converting the internal error objects to well-define error objects is straight forward. I think we can ignore extraneous properties in 3 and 5 is obviously correct.
However there is ambiguity between multiple properties being wrong (4.2) and 2, one or more properties are missing. Also in both 2 and 4.2, it is not enough to examine the return value of BarFoo.validate ($.env) (a)
. One have to go through the properties of a
and match that with the value
property of the error object.
In that case it would probably be better to leverage the fieldType._test
function Value -> Boolean
.
For our Type BarFoo
the function is available through types
Type ~> Array Type ~> (Value -> Boolean)
Which brings me to part III.
The original proof-of-concept function from @davidchambers:
// validate :: Type -> a -> Either (Array String) a
const validate = t => x => {
if (x == null) {
return Left ([show (x) + ' is not a member of ‘' + show (t) + '’']);
}
const missingFields = t.keys.filter (
k => !(Object.prototype.propertyIsEnumerable.call (x, k))
);
if (missingFields.length > 0) {
return Left (missingFields.map (k => show (k) + ' field is missing'));
}
for (const fieldName of t.keys) {
const fieldType = t.types[fieldName];
const fieldValue = x[fieldName];
if (!(fieldType._test ([]) (fieldValue))) {
return Left ([
'Value of ' + show (fieldName) + ' field, ' + show (fieldValue) +
', is not a member of ‘' + show (fieldType) + '’',
]);
}
}
return Right (x);
};
This works perfectly for none-nested types.
// FooBar :: Type
const FooBar = $.NamedRecordType
('FooBar')
('')
([])
({foo: $.String,
bar: $.Number});
eq (validate (FooBar) (null))
(Left (['null is not a member of ‘FooBar’']));
eq (validate (FooBar) (undefined))
(Left (['undefined is not a member of ‘FooBar’']));
eq (validate (FooBar) ({}))
(Left (['"bar" field is missing', '"foo" field is missing']));
eq (validate (FooBar) ({foo: null}))
(Left (['"bar" field is missing']));
eq (validate (FooBar) ({foo: null, bar: null}))
(Left (['Value of "bar" field, null, is not a member of ‘Number’']));
eq (validate (FooBar) ({foo: null, bar: 42}))
(Left (['Value of "foo" field, null, is not a member of ‘String’']));
eq (validate (FooBar) ({foo: 'blue', bar: 42}))
(Right ({foo: 'blue', bar: 42}));
But this solution won't work for BarFoo.bar
because the test is never run.
validate (BarFoo) ({foo: 'blue', bar: '2020-10-10', fez: 2})
// -> Right ({"bar": "2020-10-10", "fez": 2, "foo": "blue"})
validate (BarFoo) ({foo: 'blue', bar: '2020-29-10', fez: 2})
// -> Right ({"bar": "2020-29-10", "fez": 2, "foo": "blue"})
A wrong date for our very simple test is accepted.
I could add test for super types to validate
:
// validate :: Type -> a -> Either (Array String) a
const validate = t => x => {
if (x == null) {
return S.Left ([S.show (x) + ' is not a member of ‘' + S.show (t) + '’']);
}
const missingFields = t.keys.filter (
k => !(Object.prototype.propertyIsEnumerable.call (x, k))
);
if (missingFields.length > 0) {
return S.Left (missingFields.map (k => S.show (k) + ' field is missing'));
}
for (const fieldName of t.keys) {
const fieldType = t.types[fieldName];
const fieldValue = x[fieldName];
if (!(fieldType._test (fieldType.supertypes) (fieldValue))) {
return S.Left ([
'Value of ' + S.show (fieldName) + ' field, ' + S.show (fieldValue) +
', is not a member of ‘' + S.show (fieldType) + '’',
]);
}
}
return S.Right (x);
};
But the issue is not that validate
didn't test super types since $.NonEmpty
doesn't have a super type. The issue is that we do not call _test
on nested types. E.g. BarFoo.bar.types.$1._test()
.
ERRATA: It turns out that fieldType._test (fieldType.supertypes) (fieldValue)
is not how you test super types. I still do not know how to do that...
I believe the following change to make validate
a recursive function should work, though not thoroughly tested.
// validate :: Type -> a -> Either (Array String) a
const validate = t => x => {
if (x == null) {
return S.Left ([S.show (x) + ' is not a member of ‘' + S.show (t) + '’']);
}
const missingFields = t.keys.filter (
k => !(Object.prototype.propertyIsEnumerable.call (x, k))
);
if (missingFields.length > 0) {
return S.Left (missingFields.map (k => S.show (k) + ' field is missing from ' + S.show (t)));
}
for (const fieldName of t.keys) {
const fieldType = t.types[fieldName];
const fieldValue = x[fieldName];
if (!(fieldType._test ($.env) (fieldValue))) {
return S.Left ([
'Value of ' + S.show (fieldName) + ' field, ' + S.show (fieldValue) +
', is not a member of ‘' + S.show (fieldType) + '’',
]);
}
if (fieldType.keys.length) {
return validate (fieldType)
(S.foldMap (Object)
(k => ({ [k]: x[fieldName] }))
(fieldType.keys));
}
}
return S.Right (x);
};
I realize that my version relies on Sanctuary which will create a cyclic dependency but if the idea works out, we can convert it to an Z
(sanctuary-type-classes) implementation later.
validate (BarFoo) ({foo: 'blue', bar: '2020-10-10', fez: 2})
// -> Right ({"$1": "2020-10-10"})
// test ran with x = 2020-10-10
validate (BarFoo) ({foo: 'blue', bar: '2020-29-10', fez: 2})
// -> Left (["Value of \"$1\" field, \"2020-29-10\", is not a member of ‘DateIso’"])
// test ran with x = 2020-29-10
Unfortunately I'm running out of time and are AFK the following week, so won't have time to do the next obvious steps.
validate
with different types and values.Either (Array String) a
to Either (Array Error) a
to facilitate translation or accept a StrMap
of translations.validate
in different projects and iterate over solutions.But for sure there will be a part V and VI! ;)
@davidchambers What do you think?
I have a use case where I have a RecordType like this:
And I use this function to validate it:
newProductRequestBodyType
is a post request, where I want to validate that each field is valid. Is there a simple way, to provide an error message for each field, so that I can show the right error message to the form on the client? TheLeft
just gives me the sent object back, but it doesn't say why the validation of the Recordtype failed, if that makes sense?It would be super-useful if the Left contained more details about the error.... Maybe even something I define myself when I create my RecordType.
(This issue was first mentioned on Gitter).