A form validation engine is the logic core of a form library: you declare a schema of per-field rules, hand it the form's values, and it tells you which fields are invalid and why. It is the piece that libraries like Yup, Zod, and react-hook-form resolvers wrap in a friendlier API. You will build a small one, exposed as a factory formValidationEngine(schema), and it must collect every error — not stop at the first — and cope with rules that answer asynchronously, like a "username already taken?" check against a server.
const engine = formValidationEngine(schema);
// schema: { [field]: Rule[] } each field maps to a list of rules
// A rule inspects one field's value (and, if it needs it, the whole form)
// and returns a message when invalid, or nothing when valid. It may be async.
type Rule = (value, allValues) => string | undefined | Promise<string | undefined>;
engine.validate(values);
// -> Promise<{ valid: boolean, errors: { [field]: string[] } }>
// collects ALL failing messages per field; passing fields are absent
engine.validateField(name, values); // -> Promise<string[]> one field's messages
// Built-in rule creators (each returns a Rule):
required(message?); // present and not blank
minLength(n, message?); // at least n characters
pattern(regex, message?); // matches a regular expression
const engine = formValidationEngine({
name: [required('Name is required')],
password: [minLength(8, 'Too short'), pattern(/[0-9]/, 'Needs a digit')],
});
await engine.validate({ name: '', password: 'abc' });
// {
// valid: false,
// errors: {
// name: ['Name is required'],
// password: ['Too short', 'Needs a digit'], // BOTH failures, in order
// },
// }
// A cross-field rule reads another field; an async rule checks the server.
const taken = ['ada'];
const engine = formValidationEngine({
username: [async (value) => (taken.includes(value) ? 'Taken' : undefined)],
confirm: [(value, all) => (value === all.password ? undefined : 'Must match')],
});
await engine.validate({ username: 'grace', password: 'x', confirm: 'x' });
// { valid: true, errors: {} } nothing failed, so errors is empty
errors at all.values object as its second argument, so a confirmPassword rule can compare against values.password.validate returns a promise that resolves only after every rule, sync and async, has settled. Run them concurrently.valid follows the errors — it is true exactly when no field has any message.