Form validators are comprised of validation rules. Each of these rules receive:
- The name of a field or collection to validate
- A unique, human-readable key to describe the validation
- A function that validates the field or collection
- An optional function to validate the entire form data object
- An optional function to combine the results of the main validation result and the form data validation result
Individual form fields are validated using the validateField
function:
validateField(fieldName, validationDescription, fieldValidator, formDataValidator, resultCombinator)
This is the name of the field to validate and corresponds to the name
attribute of the input element. For example, an input with the name "country"
<input name="country" />
would have a field validation with the same name
validateField('country', ...);
The validation description has two purposes.
Firstly, it provides an easy way for the developer to understand the nature of the validation. For example, a country input might need at least 3 characters:
validateField('country', 'must be at least 3 characters', ...);
Secondly, it provides a unique key to look up the human-readable error text to show if the validation fails. These text fragments are exposed via an errors dictionary, keyed by the fieldName
and the validationDescription
.
Each form validator has a separate errors dictionary that may be unique to that validator or composed from a common errors library:
export default {
country: ['You must enter a valid country', {
'must be at least 3 characters': 'Country must be at least 3 characters',
'must be less than 100 characters': 'Country must be less than 100 characters',
}],
};
More on the specifics of errors dictionaries to follow.
The field validator is the function that determines whether the field is valid. In most cases this will be something very simple, checking a length for example:
const atLeast3Characters = value => value.length >= 3;
validateField('country', 'must be at least 3 characters', atLeast3Characters);
However, often the concern is significantly more complicated. When dealing with validations that rely on multiple assertions, it necessary to create a single function with multiple responsibilities.
To appease the god of SRP and improve maintainability, we should try to create this validator function by composing multiple simpler functions. Functional programming libraries such as ramda
and lodash/fp
can make this easier to manage.
import { both } from 'ramda';
const atLeast3Characters = value => value.length >= 3;
const onlyLetters = value => /^[a-z]*$/i.test(value);
validateField('country', 'must be at least 3 characters', both(atLeast3Characters, onlyLetters));
The advantage of this pattern is decreased complexity meaning increased readability and maintainability. Also, each of these validator functions can be extracted into a validation function library, for reuse in other form validators, decreasing repetition.
The form data validator is an optional function that, rather than validating the named field, validates the entire serialized form object; meaning the form data validation function receives an object rather than a single value. This is generally useful when your field validation relies on the value of other fields in the form.
An example might be a country validation that is optional unless the user enters a first name. Extending the example above, we could add an extra function that checks the contents of the form data object:
const firstNameisEmpty => formData => formData.firstName.length === 0;
validateField('country', 'must be at least 3 characters', atLeast3Characters, firstNameisEmpty);
You'll notice the form data validator is checking that the first name is empty. This is because the default behaviour when evaluating the result of the overall field validation is to succeed if either of the validation functions returns true
. So our field validation passes when either: the field value is 3 characters or more; or when the firstName
is empty.
To change this behaviour you can override the optional
resultCombinator
function.
This approach is fine, however, the function above is doing multiple things again! It's getting the firstName
from the formData
object and checking its length. This might seem trivial when the examples are this simple but as your validation library scales it will become harder and harder to maintain this pattern. For example, if we want to check multiple values in the form data, the complexity of our missingFormData
function increases rapidly. Checking several values would result in a single, monolithic expression:
formData => formData.firstName.length === 0 || formData.age.length === 0
An equivalent alternative, in the functional programming style, might look like this:
import { isEmpty, get, pipe } from 'lodash/fp';
const getFirstName = get('firstName');
const getAge = get('age');
const firstNameisEmpty = pipe(getFirstName, isEmpty);
const ageIsEmpty = pipe(getAge, isEmpty);
validateField('country', 'must be at least 3 characters', atLeast3Characters, either(firstNameisEmpty, ageIsEmpty));
Now we've got several extra lines of code to achieve the same goal, which is bad, right? But the advantage is that each line is responsible for a single task, and groups of similar functions are emerging.
Let's break it down a bit. The first two lines define functions that get values from objects. In this case, firstName
and age
. Let's call these functions "getters".
The isEmpty
function takes a value and returns true
or false
depending on a condition. This is exactly like the field validation functions above (such as atLeast3Characters
). Let's call these "field assertions".
The final two lines define functions that combine a getter and a field assertion. Each function uses a getter function to select a specific field in an object, then pipes the value of that field into an assertion. Let's call these "data assertions".
Let's use this convention to create a modular structure for defining field validations that doesn't increase in complexity as the validation library scales!
getters.js
export const getFirstName = get('firstName'); export const getAge = get('age');
field-assertions.js
export const atLeast3Characters = value => value.length >= 3; export const onlyLetters = value => /^[a-z]*$/i.test(value);
data-assertions.js
import { getFirstName, getAge } from './getters.js'; import { atLeast3Characters } from './field-assertions.js'; export const firstNameIsAtLeast3Characters = pipe(getFirstName, atLeast3Characters); export const ageIsAtLeast3Characters = pipe(getAge, atLeast3Characters); export const ageAndFirstNameAreAtLeast3Characters = both(firstNameIsAtLeast3Characters, ageIsAtLeast3Characters);
contact-form-validator.js
import FormValidator from 'portals-employee/validators/form-validator'; import { onlyLetters } from './field-assertions'; import { ageAndFirstNameAreAtLeast3Characters } from './data-assertions'; const SetupRolloverFromAccountValidator = new FormValidator(); const { validateField } = SetupRolloverFromAccountValidator; validateField('country', 'must be only letters', onlyLetters, ageAndFirstNameAreAtLeast3Characters);
The form validator is suddenly extremely terse and and easy to understand!
Moving forward, we will add new getters to the getters library whenever we need to get a value from an object. This file becomes the source of truth and by following a naming convention and alphabetising the exports we can easily tell if there's already a getter that we can use instead of creating a new one. A win for reducing duplication.
All field assertions become simple functions that check a single condition against a value. Again, when we need to assert anything we can check the library to find out if there's an existing assertion that will suit our purpose.
Data assertions are a bit looser, some of these might be specific to the data structure that we're asserting against. Each data assertion contains a getter and a field validation, or can combine multiple data assertions using combinators such as both
, either
, anyPass
, allPass
and nonePass
found in Ramda. There are lots of ways of diving up data assertions and the best approach varies, but creating a validator-specific library of assertions which builds on a common assertion library is a good place to start.
Weirdly,
nonePass
isn't actually a real thing, but you can create it like this:export const nonePass = complement(anyPass);
The result combinator is an optional function that defines how the field validation result and the form data validation result are combined. By default, the combinator is or
. This means that the field validation is deemed to be successful if either the field validator or the form data validator returns true
.
To change this behaviour, pass in a new function to compare the results:
import { and } from 'ramda';
validateField('country', 'must be only letters', onlyLetters, ageIsAtLeast3Characters, and);
Now this field validation will pass only if onlyLetters
returns true
and ageIsAtLeast3Characters
returns true
.