Day 59: Build: Custom Form Validation Engine
Design a small, schema-driven validation engine — the core idea behind libraries like Zod/Yup + React Hook Form, built from first principles.
Study
Concepts
Separate the schema, the validation runner, and the UI
A robust validation engine separates three concerns: a declarative SCHEMA describing rules per field (required, minLength, pattern, a custom validator function), a RUNNER that takes the schema + current values and produces an errors object, and the UI layer that decides WHEN to run it (on blur, on change after first blur, on submit) — conflating these makes the system rigid and hard to test in isolation.
Validating on every keystroke from the start feels responsive but is usually the wrong default UX — validating a field for the first time only on blur (or submit), and THEN switching to on-change for that specific field once it has an error, is the pattern most production forms converge on: it avoids yelling "required!" at a field the user has not even reached yet.
See It
Visualizations
Visualization
Per-field validation timing state machine
never touched — no validation run, no error shown
first validation run — error shown if invalid
once touched+invalid, re-validate every keystroke for fast feedback
error cleared, back to change-triggered re-checks only if edited again
Build It
Code Examples
A tiny schema-driven validation engine
const validators = {
required: (msg = 'Required') => (value) => (value?.trim() ? null : msg),
minLength: (n, msg) => (value) => (value.length >= n ? null : msg ?? `Must be at least ${n} characters`),
pattern: (regex, msg) => (value) => (regex.test(value) ? null : msg),
custom: (fn) => fn, // fn: (value, allValues) => string | null
};
function runValidation(schema, values) {
const errors = {};
for (const field in schema) {
for (const rule of schema[field]) {
const error = rule(values[field], values);
if (error) { errors[field] = error; break; } // first failing rule wins per field
}
}
return errors;
}
const signupSchema = {
email: [validators.required('Email is required'), validators.pattern(/.+@.+\..+/, 'Invalid email')],
password: [validators.required(), validators.minLength(8)],
confirmPassword: [
validators.custom((value, all) => (value === all.password ? null : 'Passwords must match')),
],
};A useForm hook consuming the engine, with correct blur/change timing
function useForm(schema, initialValues) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
function handleChange(field, value) {
setValues((v) => ({ ...v, [field]: value }));
if (touched[field]) { // only re-validate live once the field has been touched once
setErrors((e) => ({ ...e, ...runValidation({ [field]: schema[field] }, { ...values, [field]: value }) }));
}
}
function handleBlur(field) {
setTouched((t) => ({ ...t, [field]: true }));
setErrors((e) => ({ ...e, ...runValidation({ [field]: schema[field] }, values) }));
}
function handleSubmit(onValid) {
return (e) => {
e.preventDefault();
const allErrors = runValidation(schema, values);
setErrors(allErrors);
setTouched(Object.fromEntries(Object.keys(schema).map((k) => [k, true])));
if (Object.keys(allErrors).length === 0) onValid(values);
};
}
return { values, errors, touched, handleChange, handleBlur, handleSubmit };
}Remember
Key Takeaways
- Separate schema (rules), runner (pure function producing errors), and UI (timing decisions) — each becomes independently testable.
- Validate on blur first, then switch to on-change ONLY for fields that already have an error — the standard, least-annoying UX pattern.
- A rule list per field with "first failure wins" keeps error messages focused instead of showing every violated rule at once.
- This is the same conceptual shape as Zod/Yup + React Hook Form — building it once makes those libraries' APIs feel obvious rather than magic.
- Cross-field validation (confirmPassword matching password) needs access to ALL values, not just the field being validated — design the runner signature for this from the start.
Do It
Practice
- 1Add async validation support (e.g. "check if username is taken") with a pending state shown in the UI while it resolves.
- 2Add array-field support (a dynamic list of "team members" each with their own nested validation).
- 3Compare your engine's API against Zod's schema API and note two things Zod handles that yours currently does not.