gcanti / tcomb-validation

Validation library based on type combinators
MIT License
400 stars 23 forks source link

Another custom messages question #21

Closed a-s-o closed 9 years ago

a-s-o commented 9 years ago

Hello, I am using tcomb-validation but cannot figure out how to use it to display custom error messages. I am currently using my own code to validate form models as follows and would appreciate some direction as to how I can switch to using tcomb-validation which is otherwise great for typechecking.

Here is an example of what I am currently doing:

const string = (msg) => {
    return function validate$string (arg) {
        let value = _.isFunction(arg) ? arg() : arg;

        if (!value || !_.isString(value)) {
            return msg || `"${typeof value}" provided instead of "string"`;
        }
    };
};

const min = (check, msg) => {
    msg = msg || `Value should be at least ${check}`;
    return function validate$min (arg) {
        let value = _.isFunction(arg) ? arg() : arg;

        if (_.isFinite(Number(value))) {
            if (value < check) return msg;
        } else {
            if (_.size(value) < check) return msg;
        }
    };
};

const all = function (...args) {
    const predicates = _.flatten(args);
    return function validate$all (value) {
        for (let pred of predicates) {
            let err = pred(value);
            if (err) return err;
        }
    };
};

I can use the above functions as follows to display custom error messages:

// Generate a validator and provide custom error messages
const validatePassword = all([
    string('Password must be provided'), 
    min(10, 'Short passwords are not allowed; please use at least 10 characters')
]);

validatePassword('1234567') //=> 'Short passwords are not allowed...'

Basically validatePassword returns the first error message that it encounters.

Another example:

const validateStartDate = dateIsAfter(Date.now(), 'Date must be in the future');

// Validator gets the value lazily
const validateEndDate = dateIsAfter(() => startDate, 'Date must be after start date');

Is something like this possible with tcomb-validation? I couldn't figure it out from the documentation so far.

Somewhat related, how do tcomb-validation and tcomb-form keep types from being disable in production mode? I though that the type checking gets disable when NODE_ENV === 'production'. I like that but in case of forms, one would want to keep the checking enabled.

Thanks for your help and a providing a great tool.

gcanti commented 9 years ago

Hi, tcomb-validation is a low level library so emitted messages are targetting developers but since validation errors contain the whole validation infos:

you could build a custom error message system. For example

import t from 'tcomb-validation';

const Email = t.subtype(t.Str, x => x.indexOf('@') !== -1, 'Email');
const LongPassword = t.subtype(t.Str, x => x.length > 10, 'LongPassword');
const FutureDate = t.subtype(t.Dat, x => x.getTime() > new Date().getTime(), 'FutureDate');

const MyType = t.struct({
  email: Email,
  password: LongPassword,
  date: FutureDate
});

// a simple message system based on a hash: Type name -> message

function getMessages(errors, messages) {
  return errors.map(error => {
    return messages[error.expected.meta.name];
  });
}

// the message system in action

const messages = {
  Email: 'Invalid email',
  LongPassword: 'Short passwords are not allowed; please use at least 10 characters',
  FutureDate: 'Date must be in the future'
};

const result = t.validate({
  email: 'badEmail',
  password: 'password',
  date: new Date(1973, 10, 30)
}, MyType);

console.log(JSON.stringify(getMessages(result.errors, messages), null, 2));

output:

[
  "Invalid email",
  "Short passwords are not allowed; please use at least 10 characters",
  "Date must be in the future"
]

related (but old) issue:

https://github.com/gcanti/tcomb-validation/issues/5#issuecomment-62228216

a-s-o commented 9 years ago

Wow, thanks for writing that out. I did take a look at #5 but was confused. The above is much clearer.

So as I understand the ValidationResult output from t.validate just provides a destructured array of errors and does not preserve the structure of the initial type? Is there any way to preserve that?

i.e. let's say the type is defined as in your example above, we get back the following result from validation:

const MyType = t.struct({
   email: Email,
   password: LongPassword,
   start: FutureDate,
   end: t.all([AfterStart, FutureDate])   // => made up combinator for specifying multiple types
});

const result = t.validate({
  email: 'badEmail',
  password: 'password',
  start: new Date(2020, 10, 30),
  end: new Date(2015, 10, 30)
}, MyType);

Given the above, the result.errors could be as follows (basically preserving the original data structure):

{
   email: [EmailError],
   password: [PasswordError],
   start: null,       // => no error for this field
   end: [AfterStartError]
}

Okay, as I write that out, I understand what you are saying that all the errors have paths, we should be able to build the output structure ourselves. I will take a stab at that. If I come up with something useful, I will post back here.

Thanks again. Your response was very helpful.

Can I also ask my other question again? Will any such validation system built using tcomb get disabled in production when NODE_ENV === 'production'? Is there a way to prevent that?

gcanti commented 9 years ago

I will post back here.

Yes please, I'd be glad to see a good message system on top of tcomb-validation. I think the most difficult problem is how to handle subtypes (and perhaps unions):

Can I also ask my other question again? Will any such validation system built using tcomb get disabled in production when NODE_ENV === 'production'? Is there a way to prevent that?

Oh sorry, I forgot the other question. Validations will be preserved. Every tcomb type is a function T such that

the asserts will be stripped out in production, though the function T.is will be preserved.

In short

// development
const a = t.Str(1); // throws
console.log(t.Str.is(1)); // => false

// production
const a = t.Str(1); // => a = 1
console.log(t.Str.is(1)); // => false

Note. asserts shouldn't ever be used as a validation system but just for type checking.

Since tcomb-validation's internals use extensively such is functions (example https://github.com/gcanti/tcomb-validation/blob/master/index.js#L79) validations are active also in production.

a-s-o commented 9 years ago

Thanks for the explanation Giulio. I really appreciate your thorough explanations.

I have started changing over some of my validations to tcomb. Once I develop some understanding of the lib, I will start figuring out what the best api for messages may be. I may ask for your help again.

Closing the issue for now.

a-s-o commented 9 years ago

Hi @gcanti,

This is what I came up with for displaying messages using tcomb. I propose implementing two functions. First is a T.isnt function on each type or subtype which takes an optional message as argument and returns a inverted predicate, which instead of returning a boolean, returns the original message.

The second function is a combinator t.invalidate which accepts and array of functions and returns the first error or empty string if validation passed.

Your input is appreciated.

Usage:

var checkEmail = t.invalidate([
  t.Str.isnt('Please provide an email address'),
  t.ShortStr.isnt('Email appears incomplete'),
  t.Email.isnt('That is not a valid email address')
]);

var checkPassword = t.invalidate([
  t.Str.isnt('Password not provided'),
  t.ShortStr.isnt('Password is too short. Min 5 chars'),
  t.SecurePass.isnt('Password must contain a uppercase letter, lowercase letter and number')
]);

// elsewhere in a submit handler
function onsubmit (formdata) {

  const emailError = checkEmail(formdata.email);
  const passError = checkPassword(formdata.password);

  if (emailError || passError) {
     // Display the errors
  } else {
     // Submit the form
  }

}

Implementation:

// f: [list(Func)] -> Func
t.invalidate = function (preds) {
  const len = preds.length;
  return function (value) {
    let fn;
    let i = 0;
    while (t.Func.is(fn = preds[i++])) {
      const err = fn.call(this, value);
      if (err) return err;
    }
    return '';
  }
};

// Helper to quickly create inverted predicates
// f: [T] -> Func
function invert (type) {
  // f: [Str] -> Func
  return function (msg) {
    // f: [Any] -> maybe(Str)
    return function (value) {
      return type.is(value) ? null : msg;
    };
  };
}

// Define isnt methods on the types as follows
t.Str.isnt          = invert(t.Str);
t.ShortStr.isnt     = invert(t.ShortStr);
t.SecurePass.isnt   = invert(t.SecurePass);
t.Email.isnt        = invert(t.Email);
a-s-o commented 9 years ago

Just fixing mistakes above, and realized that there is no need to have T.isnt on each type. The isnt fn can just be a combinator to be used as follows:

t.invalidate([
  t.isnt(t.Str, 'Password is required'),
  t.isnt(t.LongStr, 'Password is too short'),
  t.isnt(t.SecurePass, 'Passwords must contains an uppercase, lowercase, and number')
])
gcanti commented 9 years ago

Hi @a-s-o The following issue on tcomb may interest you (there's also an implementation of a message system):

https://github.com/gcanti/tcomb/issues/111

I think that introducing a new intersection combinator, in addition to being interesting theoretically, can help in designing a general message system.

gcanti commented 9 years ago

Also

https://github.com/gcanti/tcomb/issues/88#issuecomment-97467342

a-s-o commented 9 years ago

Oh, wow. That intersection combinator is almost exactly the same. I'll look forward to what becomes of it in v2.2. Thanks.