Jack Roper

@zcabjro/vivalidate v0.6.0 custom errors in the run up to forms

@zcabjro/vivalidate is a validation library written in typescript that focuses on being small and easy to use. Using simple primitives like string and object, you can build complex schemas and then validate incoming data at runtime while getting type declarations for free in the process.

Applying vivalidate to forms

Prior to v0.6.0, if we were to try and use vivalidate to validate a user-facing form, we would definitely have struggled. As a common example, suppose we are coding up a login page. We could start with a schema such as this one:

const schema = v.object({
  email: v.string(),
  password: v.string(),
});

This is a good bare minimum, but for some time we've been able to do better by adding some constraints.

Constraints are functions applied to a validated value that either fail or return the value exactly as it was. They do not transform anything; they simply ensure that conditions are met.

const schema = v.object({
  email: v.string().email(),
  password: v.string().lengthGreaterThan(9),
});

Now, the process of integrating this schema into our form is still a work-in-progress, but here's a quick and dirty version using React to give us something to work with.

type LoginForm = v.Infer<typeof schema>;

const [errors, setErrors] = useState<v.ValidationError[]>([]);
const [state, setState] = useState<LoginForm>({
  email: '',
  password: '',
});

const validateAndSet = (state: LoginForm) => {
  schema.validate(state).swap().map(setErrors);
  setState(state);
};

const getError = (field: string) => errors.find(e => e.path === field)?.message;

return (
  <form>
    <div>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        value={state.email}
        onChange={e => {
          validateAndSet({ ...state, email: e.currentTarget.value });
        }}
      />
      <span>{getError('email')}</span>
    </div>

    <div>
      <label htmlFor="password">Password</label>
      <input
        id="password"
        value={state.password}
        onChange={e => {
          validateAndSet({ ...state, password: e.currentTarget.value });
        }}
      />
      <span>{getError('password')}</span>
    </div>

    <div>
      <button type="submit">Login</button>
    </div>
  </form>
);

bad errors

The result isn't just less than ideal, it's outright terrible! Since vivalidate was written for general purpose validation, many of the built-in validators and constraints include the value in their error messages. This is pretty useful in other contexts such as when reading these errors in log files (though we ought to consider the size of the inputs we'd be logging in that case). However, when filling out a form, the message is redundant. Worse yet, we're displaying the user's password input in plaintext with no option to hide it.

Custom error messages

With vivalidate v0.6.0, we can spare ourselves this embarassment by providing custom error messages to our constraints.

const schema = v.object({
  email: v.string().email('Email must be a valid email address'),
  password: v.string().lengthGreaterThan(9, 'Minimum 10 characters'),
});

better errors

Next steps

This was just a small update to unblock vivalidate from being applied to forms. Still, there's work to be done to streamline the process and cut down on boilerplate. The tricky part is doing this without sacrificing type-safety. With constraints, transforms and new custom error messages, vivalidate can express quite a lot to fit a number of use-cases. But to compete with existing form solutions like formik we need to try and match its ease-of-use while using typescript to our advantage.