README
Validator creator
Library to create client-side validation functions.
Features:
- multiple rules per field
- asynchronous rules with built-in concurrency prevention
- BYO rules and display logic
Install
npm install validator-creator
Example usage
import { createRule, createValidator } from "validator-creator";
const filled = createRule("filled", value => value.length > 0, "Required");
const rules = {
field1: [filled],
field2: [filled]
};
const validate = createValidator(rules);
const change = {
field1: ""
};
validate(change).then(([results]) => {
// --> results: [{ type: 'filled', prop: 'field1', payload: 'Required' }]
});
Rules
A "rule" is a function that validates a field in a "change object" and returns a "result object".
/**
* Example rule - say the magic word!
* @param {object} change - The change being validated
* @param {string} prop - The field to apply the rule to
* @return {object} - A result object with shape { type, prop } that identifies
* the rule and the field to associate it with
*/
function exampleRule(change, prop) {
const type = "example";
if (change[prop] === "please") {
// pass: the magic word was provided
return null;
}
// fail
return { type, prop };
}
The createRule
function can be used to help writing simple rules more simply.
// Example rule - say the magic word!
const exampleRule = createRule("example", value => value === "please");
A rule can provide a payload containing additional data related to the rule.
// the payload can be any value
createRule("example", value => false, { text: "Example payload text" });
The payload can be a callback function that returns a payload. The callback will
be passed the { type, prop }
object and the data passed to validate()
.
const example = createRule(
"example",
value => false,
({ type, prop }, data) => `${type} payload for ${prop} with value '${data[prop]}'`
);
const validate = createValidator({ field1: [example] });
validate({ field1: "test" }).then(([result]) => {
// --> result: { type: "example", prop: "field1", payload: "example payload for field1 with value 'test'" }
});
Rule collections
A rule collection assigns a list of rules to fields. These are passed to the
createValidator
function.
const rules = {
field1: [filled, exampleRule],
field2: [filled, email],
field3: [number]
};
The validator will apply the rules for each field in the order specified, stopping at the first rule that returns a non-null response.
Asynchronous rules
An asynchronous rule is called once by the validator even if multiple fields need to apply the rule.
The rule should resolve to a list of result objects with shape { type, prop }.
/**
* Checks whether the field has value 'async'
*/
async function asyncRule(change) {
const type = "async";
return Object.keys(change)
.filter(prop => change[prop] !== "async")
.map(prop => ({ type, prop }));
}
const rules = {
field1: [asyncRule],
field2: [asyncRule]
};
const validate = createValidator(rules);
const change = { field1: "abc", field2: "xyz" };
validate(change).then(([result]) => {
// --> result: [ {type: "async", prop: "field1" }, {type: "async", prop: "field2" }]
});
Asynchronous rules can be mixed with synchronous rules. In the next example, the 'email' field needs to pass three rules:
- filled -- it cannot be a blank value
- email -- it must conform to an email address format
- network -- the change is passed to a server-side validator (async)
const rules = {
email: [filled, email, network]
};
Interrupting validation
A common scenario is to validate a change from within an event handler.
async function handleChange(event) {
const {
target: { name, value }
} = event;
const change = { [name]: value };
// ... update state with the change ...
const [results] = await validate(change);
// ... translate results into validation messages ...
}
It's desirable to not have concurrent network requests build up if lots of change events occur in rapid succession. The validator will avoid this by blocking and discarding stale results.
The application should catch ValidatorError exceptions generated by the interrupted or discarded calls.
try {
const [results, mergedChange] = await validate(change);
// ... translate results into validation messages ...
// mergedChange will have the changes of interrupted calls to validate()
} catch (error) {
if (error instanceof ValidatorError) {
const { validatorErrorPayload: results } = error;
// ... handle stale results ...
}
}
The following timeline shows how this behaves.
1 2 3 4 5
A: *==------! interrupted
B: ***! discarded
C: ****==> completed
- Time 1 -- Event A occurs.
validate(A)
starts - Time 2 -- Event B occurs.
validate(B)
is blocked; A is marked as interrupted - Time 3 -- Event C occurs.
validate(C)
is blocked. B is rejected with a discarded error - Time 4 --
validate(A)
is rejected with an interrupted error.validate(C)
starts - Time 5 --
validate(C)
resolves
At time 4, the validate(C)
call is actually validate({...A, ...B, ...C})
so
no changes are ignored.
Example: client-side generated validation text
Our "server" in this example is simulated by a module that exposes an
async function, validate(change)
, that returns a list of validation results
containing { type, prop }
objects. In real life, this module would be sending
the change to an API endpoint for server-side validation.
// server.js
import { createRule, createValidator } from "validator-creator";
const filled = createRule("filled", value => value.length > 0);
const rules = {
field1: [filled]
};
const serverValidate = createValidator(rules);
export const validate = change =>
serverValidate(change).then(([result]) => result);
On the "client" an async rule name server
is created which will send the
change to the server module's validate()
function. Results from the server
are augmented with a payload.
In this case the payload is a string containing the type
and prop
values of the result. In real life you would generate an appropriate
message to display to the user based on the rule type.
Finally we transform the list of results into a "messages" object with the field name as key and the payload as value.
import {
createAsyncRule,
createValidator,
getPayload
} from "validator-creator";
import * as Server from "./server";
const server = createAsyncRule(
change => Server.validate(change),
({ type, prop }) => `${type}, ${prop}`
);
const rules = {
field1: [server]
};
const validate = createValidator(rules);
const change = {
field1: ""
};
validate(change)
.then(getPayload)
.then(messages => {
// --> messages: { field1: "filled, field1" }
});
Example: "max length" rule
This example demonstrates a "rule creator" pattern. This allows having rules that take arguments.
import { createRule, createValidator, getPayload } from "validator-creator";
const maxLength = length =>
createRule(
"maxLength",
value => value.length <= length,
`Maximum length is ${length} characters`
);
const rules = {
field1: [maxLength(5)],
field2: [maxLength(10)]
};
const validate = createValidator(rules);
const change = {
field1: "123456",
field2: "123456"
};
validate(change)
.then(getPayload)
.then(messages => {
// --> messages: { field1: "Maximum length is 5 characters"}
});