Validation code that starts as if (!email) errors.email = "Required"; else if (!email.includes("@")) … grows into a nested thicket where every field reimplements “required”, messages drift in wording, and adding one rule to one field means reading all of them.

Small pure functions composed with a couple of combinators give the same power as a schema library for the synchronous, per-field case — with no dependency and code you can read in one sitting. This page builds that toolkit, part of the synchronous validation patterns topic, and shows where it hands over to schemas and async checks.


Context and prerequisites

A validator here is a pure function from a value (and optional context) to either null (valid) or an error. Pure means: no DOM access, no side effects, same output for the same input. That makes validators trivial to test, safe to run on every keystroke, and reusable on the server.

Composition needs two decisions:

  • First error or all errors? Most fields show one message at a time, the first failure in a sensible order (“Enter your password” before “Use at least 12 characters”). Password rule checklists want all failures at once.
  • How do rules see other fields? Cross-field rules (“confirm must match password”) need the whole form’s values as context, passed as a second argument — never read from a closure over mutable state.
The building blocks Atomic rules such as required, min length and pattern each check one thing and return an error code and message or null. The first combinator runs rules in order and returns the first failure. The when wrapper applies a rule only if a condition on the form values holds. The form runner applies each field's composed validator with the whole form as context and collects one error per field. Atomic rules required, minLength, pattern, custom. first(...) Run in order. Return the first failure. when(cond, rule) Conditional rules from form values. validateForm One error per field. Form values as context.

The core pattern: typed rules and combinators

export interface RuleError { code: string; message: string }
export type Rule<V, F = Record<string, unknown>> = (value: V, form: F) => RuleError | null;

// ---- Atomic rules: each checks ONE thing -------------------------------
const isBlank = (v: unknown) => v === undefined || v === null || (typeof v === "string" && v.trim() === "");

export const required = (message: string): Rule<unknown> =>
  (v) => (isBlank(v) ? { code: "required", message } : null);

export const minLength = (n: number, message: string): Rule<string> =>
  // Skip blank values: "required" is responsible for them, so a blank optional
  // field never shows "too short".
  (v) => (!isBlank(v) && v.trim().length < n ? { code: "minLength", message } : null);

export const pattern = (re: RegExp, message: string): Rule<string> =>
  (v) => (!isBlank(v) && !re.test(v) ? { code: "pattern", message } : null);

export const oneOf = <T>(allowed: readonly T[], message: string): Rule<T> =>
  (v) => (!isBlank(v) && !allowed.includes(v) ? { code: "oneOf", message } : null);

// ---- Combinators -------------------------------------------------------
export const first = <V, F>(...rules: Rule<V, F>[]): Rule<V, F> =>
  (v, form) => { for (const r of rules) { const e = r(v, form); if (e) return e; } return null; };

export const all = <V, F>(...rules: Rule<V, F>[]) =>
  (v: V, form: F): RuleError[] => rules.map((r) => r(v, form)).filter((e): e is RuleError => e !== null);

export const when = <V, F>(cond: (form: F) => boolean, rule: Rule<V, F>): Rule<V, F> =>
  (v, form) => (cond(form) ? rule(v, form) : null);

export const matches = <F extends Record<string, unknown>>(other: keyof F, message: string): Rule<unknown, F> =>
  (v, form) => (!isBlank(v) && v !== form[other] ? { code: "matches", message } : null);

// ---- Form runner -------------------------------------------------------
export function validateForm<F extends Record<string, unknown>>(
  values: F,
  fields: { [K in keyof F]?: Rule<F[K], F> },
): Partial<Record<keyof F, RuleError>> {
  const out: Partial<Record<keyof F, RuleError>> = {};
  for (const key of Object.keys(fields) as (keyof F)[]) {
    const e = fields[key]!(values[key], values);
    if (e) out[key] = e;
  }
  return out;
}
type Signup = { accountType: "personal" | "business"; company: string; email: string; password: string; confirm: string };

const signupRules = {
  company: when((f: Signup) => f.accountType === "business", required("Enter your company name.")),
  email: first(required("Enter your email address."), pattern(/^[^\s@]+@[^\s@]+$/, "Enter an email like [email protected].")),
  password: first(required("Create a password."), minLength(12, "Use at least 12 characters.")),
  confirm: first(required("Re-enter your password."), matches<Signup>("password", "Passwords do not match.")),
};

const errors = validateForm(values, signupRules);

Step-by-step walkthrough

  1. Write atomic rules that check one thing. required checks presence; minLength checks length and deliberately passes blanks so an optional field never says “too short”.
  2. Order rules with first. Presence, then format, then business rules — so the message always addresses the most basic problem.
  3. Pass form values as context. Cross-field rules read form, not outer variables, so they stay pure and testable — the approach in password confirmation validation pattern.
  4. Express conditions with when. A rule that only applies for business accounts is data-driven and visible in the rules table, matching the relevance idea in what happens to errors when a field is hidden.
  5. Return codes as well as messages. Codes let the UI choose presentation (a required marker, a checklist tick) and let translation happen outside the rule.
  6. Keep the rules table next to the form. One object describes every field’s validation; reviewers see all of it at once.

Why pure functions pay off beyond forms

Because each rule is a plain function with no dependencies, the same rules table can run in the browser on blur, in a unit test with a table of inputs, in a Web Worker for large forms, and on the server in a Node API route. The table becomes a small, shared contract. Compared with ad-hoc if chains inside components, pure rules also make timing a separate concern: what is valid is defined once, and when to show it is decided by the form’s timing policy — the split that reward early, punish late depends on.

The signup rules table evaluated for one input For a personal account, the company rule is skipped by when, so no error. For the email ada at, required passes and the pattern fails, returning enter an email like name at example dot com. For the password short, required passes and minimum length fails, returning use at least 12 characters. For the confirm field containing shorter, required passes and matches fails, returning passwords do not match. Field Value Rules run Result company "" (personal) when → skipped none email "ada@" required ✓ → pattern ✗ Enter an email like [email protected]. password "short" required ✓ → minLength ✗ Use at least 12 characters. confirm "shorter" required ✓ → matches ✗ Passwords do not match.

Failure modes and edge cases

1. Rules that depend on “now”

A “must be in the future” rule that calls new Date() inside is impure and flaky in tests. Pass the current time in context (form.__now or a separate context argument) so tests can fix it.

2. Blank handling in every rule

If minLength does not skip blanks, an empty optional field shows “too short”. Centralise isBlank and use it in every rule except required.

3. Trimming inconsistently

One rule trims, another does not, and " " is “present” to one and “blank” to another. Decide once: trim for presence and length checks, and normalise the stored value on blur if leading and trailing spaces are never meaningful.

4. Growing into a schema library

When you need nested objects, arrays with index paths, type coercion and inferred TypeScript types, a schema library does it better. Keep composed validators for field-level UX rules, and let a schema such as Zod own the data contract — the trade-off discussed in choosing a schema validation library.

5. Performance

Pure rules are cheap, but running every rule of a 300-field form on each keystroke is wasteful. Validate the changed field (and its dependents) per keystroke, and the whole table on submit.

Rules run per keystroke in a 300-field form Counting validator invocations for one keystroke in a form of 300 fields where the edited field has two dependents. Validating the whole form runs all 300 field validators. Validating only the changed field and its dependents runs three. validate whole form 300 validators changed field + dependents 3 validators A count, not a timing: the whole-form pass belongs on submit.

Verification checklist


Frequently Asked Questions

Should I use this instead of Zod?

They solve overlapping problems. Composed validators excel at field-level UX rules with precise ordering and messages; schemas excel at typed data contracts, nesting and coercion. Many teams use a schema for the payload and a few composed rules for fine-grained field feedback.

How do I handle async rules in the same table?

Keep them separate. Synchronous rules run instantly and gate the async ones: only when the synchronous validator for a field passes should an availability check start, as in implementing async email availability checks.

Where do translated messages fit?

Pass message keys instead of sentences (required("errors.email.required")) and translate at render, or build the rules table per locale. Codes make both approaches straightforward.


Related

← Synchronous Validation Patterns