Composing Pure Validator Functions into a Typed Validation Pipeline

A field rarely has one rule — it has several (required, length, format, allowed characters) that must run in a defined order and report failures in a predictable shape; this recipe composes small pure predicate functions into a single pipeline, with a typed ValidationResult and a clear choice between early-exit (stop at the first failure) and exhaustive (collect every failure) evaluation.

When to Use This Recipe

Use composed pure validators whenever a field’s validity is the conjunction of multiple independent, synchronous rules and you want each rule to be testable, reusable, and free of DOM side effects. This is the foundational building block under Synchronous Validation Patterns, and it pairs cleanly with the site’s <form novalidate> house style: the pipeline computes a result, and a thin adapter writes it to setCustomValidity.

Pick this approach when:

  • A field has three or more rules that you would otherwise cram into one tangled if ladder.
  • The same rule (e.g. “non-empty”, “max length”) recurs across many fields and should be defined once.
  • You want pure functions you can unit-test in isolation, with no document, no network — anything async belongs behind an asynchronous server check instead.

If your validation is a single comparison spanning two fields, that is a cross-field concern handled in Cross-Field Validation Strategies; composition is for stacking several rules over a single value.

Early-exit versus exhaustive validator composition A value passes through required, length, and format validators. In early-exit mode evaluation stops at the first failing predicate. In exhaustive mode all predicates run and their failures are collected into one result. Two evaluation strategies, same predicates required maxLength pattern early-exit first error only required maxLength pattern exhaustive all errors collected
The same predicate set drives both strategies — early-exit returns at the first failure, exhaustive runs every rule and aggregates the errors.

Minimal Working Implementation

Start with a precise result type and a Validator signature: a pure function from a value to either null (pass) or a message (fail). Each validator factory closes over its configuration so the rules read declaratively.

// A single rule: pure, synchronous, returns an error message or null.
type Validator<T> = (value: T) => string | null;

// The result the pipeline produces.
type ValidationResult =
  | { valid: true }
  | { valid: false; errors: string[] };

// --- Reusable rule factories (pure, no DOM, no I/O) ---
const required = (msg = 'This field is required.'): Validator<string> =>
  (v) => (v.trim().length > 0 ? null : msg);

const maxLength = (n: number, msg = `Must be ${n} characters or fewer.`): Validator<string> =>
  (v) => (v.length <= n ? null : msg);

const matches = (re: RegExp, msg: string): Validator<string> =>
  (v) => (re.test(v) ? null : msg);

The composer takes the strategy as an explicit argument so the call site decides whether to stop early or run everything:

function compose<T>(
  validators: Validator<T>[],
  mode: 'early-exit' | 'exhaustive' = 'early-exit',
): Validator<T> {
  return (value: T): string | null => {
    const errors: string[] = [];
    for (const validate of validators) {
      const error = validate(value);
      if (error) {
        if (mode === 'early-exit') return error; // stop at first failure
        errors.push(error);                       // exhaustive: keep going
      }
    }
    return errors.length ? errors.join(' ') : null;
  };
}

// Building the field rule once, reusing it everywhere.
const validateUsername = compose<string>(
  [
    required('Username is required.'),
    maxLength(20),
    matches(/^[a-z0-9_]+$/i, 'Only letters, numbers, and underscores.'),
  ],
  'early-exit',
);

A thin, impure adapter is the only place that touches the DOM — it bridges the pure result into the Constraint Validation API used throughout the site:

function applyTo(input: HTMLInputElement, rule: Validator<string>): boolean {
  const error = rule(input.value);
  input.setCustomValidity(error ?? '');
  input.setAttribute('aria-invalid', String(error !== null));
  return error === null;
}

const username = document.querySelector<HTMLInputElement>('#username')!;
username.addEventListener('blur', () => {
  applyTo(username, validateUsername);
  username.reportValidity();
});

Parameter & Option Reference

Parameter / Option Type Default Purpose
validators Validator<T>[] Ordered list of pure rules; order matters in early-exit mode.
mode 'early-exit' | 'exhaustive' 'early-exit' Stop at the first failure, or run every rule and aggregate.
Validator<T> return string | null null means pass; a string is the user-facing message.
ValidationResult discriminated union { valid: true } or { valid: false; errors: string[] } for type-safe consumption.
rule factory args number / RegExp / string Config (length limit, pattern, message) closed over by each factory.
adapter (applyTo) impure boundary The single place DOM writes happen; keeps the pipeline pure.

Verification Steps

A Vitest check covering both strategies:

import { expect, test } from 'vitest';

test('exhaustive collects all errors, early-exit returns the first', () => {
  const rules = [required(), maxLength(3, 'Too long.')];
  const longEmpty = '    abcd'; // fails required (trim) AND maxLength
  expect(compose(rules, 'early-exit')(longEmpty)).toBe('This field is required.');
  expect(compose(rules, 'exhaustive')(longEmpty)).toContain('Too long.');
});

Edge Cases & Failure Modes

Order-dependent early-exit hiding the real problem. In early-exit mode the user fixes one error only to be shown the next, which can feel like whack-a-mole. Order rules from most fundamental to most specific (required → length → format) so the first surfaced message is the most useful, and prefer exhaustive mode when the rules are independent and a complete error list helps the user more than a single hint.

Validators that are secretly impure. A rule that reads Date.now(), a global config object, or the DOM is no longer a pure function — it becomes untestable and order-sensitive in subtle ways. Keep every Validator<T> dependent only on its input argument; inject anything dynamic (like the current date for an age check) as a factory parameter so the function stays referentially transparent.

Empty-value handling colliding with required. If a non-required field runs a format rule, an empty string may fail the pattern even though blank should be allowed. Make optional rules short-circuit on empty input (if (!v) return null;) and let a separate required rule decide whether emptiness is itself an error, so the two concerns never fight.

Frequently Asked Questions

Should I default to early-exit or exhaustive evaluation?

Early-exit is the safer default for a single field because showing one clear, prioritized message at a time avoids overwhelming the user — and it matches the Constraint Validation API, which surfaces one validationMessage per field. Switch to exhaustive when the rules are independent (e.g. a password's length, case, and symbol requirements) and a complete checklist of what is missing is more helpful than a single hint.

How do I keep these validators pure when the rules need configuration?

Use factory functions that close over the configuration and return a Validator<T>. maxLength(20) returns a pure function whose only input is the value; the limit is captured at construction time. This keeps each returned validator referentially transparent and trivial to unit-test, while still letting you parameterize messages and thresholds.

Can I compose async checks into the same pipeline?

Keep them separate. This pipeline is intentionally synchronous so it can run on every keystroke with no race conditions. Run the cheap pure rules first, and only if they all pass dispatch the network-backed check described in Asynchronous Server Checks. Mixing promises into the predicate chain forfeits the simplicity and testability that make composition worthwhile.

← Back to Synchronous Validation Patterns