Short-Circuiting a Synchronous Validation Chain on the First Failure

When a field runs through several ordered synchronous rules, you often want to stop at the first one that fails so the user sees a single, actionable message instead of a wall of stacked errors, and this page shows how to short-circuit that chain while still feeding the result back through the Constraint Validation API.

When to Use This First-Failure Approach

Short-circuiting is the right default when your rules are naturally ordered from cheap and structural (required, format) to expensive or specific (length, business rules) and a later rule’s message would be meaningless while an earlier rule is still broken. Telling someone their email is “too long” before it is even a valid address is noise.

Reach for this over the alternatives when:

  • You want exactly one message per field at a time, matching most native browser behaviour.
  • Your rules have a dependency order — later checks assume earlier ones passed.
  • You are composing small predicates, as covered in Composing Pure Validator Functions.

Prefer a collect-all strategy instead when every rule is independent and you want a complete checklist surfaced at once, or when cross-field validation means several messages are simultaneously relevant. For rules that hit the network, see asynchronous server checks — those cannot run in a purely synchronous chain like this one.

Short-circuiting a synchronous validation chain Ordered rules run left to right, the first failure halts the chain, and one message is reported. One field, ordered rules, first failure wins Run rules in order required → format → length Stop at first fail return the rule's message setCustomValidity reportValidity() shows it
The chain evaluates rules in order and hands the first failure's message to the native validity pipeline.

Building the Chain with Array.prototype.find and setCustomValidity

The minimal implementation keeps the baseline intact: the form ships novalidate, each field owns an ordered list of pure rules, and the first failing rule’s message is pushed into the native validity object via setCustomValidity. A single reportValidity() call then drives the UI.

// A rule is a pure predicate returning null when valid, or a message when not.
type Rule = (value: string) => string | null;

const required: Rule = (v) => (v.trim() === "" ? "This field is required." : null);
const isEmail: Rule = (v) =>
  /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v) ? null : "Enter a valid email address.";
const maxLen = (n: number): Rule => (v) =>
  v.length <= n ? null : `Keep this under ${n} characters.`;

// Short-circuit: `find` stops at the first rule that returns a message.
function firstError(value: string, rules: Rule[]): string | null {
  const failing = rules.find((rule) => rule(value) !== null);
  return failing ? failing(value) : null;
}

const form = document.querySelector<HTMLFormElement>("#signup")!;
const email = form.elements.namedItem("email") as HTMLInputElement;
const emailRules: Rule[] = [required, isEmail, maxLen(254)];

// Re-run on input so a stale custom message never blocks a now-valid field.
email.addEventListener("input", () => {
  const message = firstError(email.value, emailRules);
  email.setCustomValidity(message ?? ""); // "" clears the error state.
});

form.addEventListener("submit", (event) => {
  email.setCustomValidity(firstError(email.value, emailRules) ?? "");
  // Single manual gate. reportValidity() focuses + shows the first invalid field.
  if (!form.reportValidity()) {
    event.preventDefault();
  }
});

Because find returns as soon as the predicate is truthy, later rules never run once an earlier one fails — that is the short-circuit. Ordering the array is how you control which message a user sees first.

Rule Chain Configuration Reference

Option Type Default Purpose
rules Rule[] [] Ordered predicates; array index defines evaluation and message priority.
Rule return string | null null means pass; any string is the message and halts the chain.
firstError (value, rules) => string | null Runs the chain and yields the first failing message.
trigger event "input" | "blur" | "submit" "input" When the chain re-runs; input clears stale custom validity fastest.
setCustomValidity arg string "" Non-empty marks the field invalid; "" clears it.
form attribute novalidate present Suppresses native bubbles so your single reportValidity() owns timing.

Verification Steps

import { test, expect } from "@playwright/test";

test("chain reports only the first failure", async ({ page }) => {
  await page.goto("/signup");
  await page.click("button[type=submit]");
  const email = page.locator("#email");
  await expect(email).toHaveJSProperty("validationMessage", "This field is required.");

  await email.fill("not-an-email");
  await page.click("button[type=submit]");
  await expect(email).toHaveJSProperty("validationMessage", "Enter a valid email address.");
});

Edge Cases & Failure Modes

Stale custom validity blocks a valid submit. If you only set the message on submit and never clear it on input, a field that becomes valid still carries the old non-empty string and reportValidity() keeps failing. Fix: re-run the chain on every input and call setCustomValidity("") when the result is null.

Order hides the message the user actually needs. Putting an expensive or narrow rule before a structural one surfaces a confusing message first. Fix: sort rules cheap-and-structural first (required → format → length → business), so the earliest failure is always the most fundamental one.

A single message defeats accessible error output. Short-circuiting means only one message exists at a time, so any aria-live region must be updated to that exact string — do not append. Pair this with accessible error messaging and focus management after a failed submit so screen reader users hear the current first failure and land on the right field.

Wiring One Chain Per Field with a Config Map

The single-field example scales awkwardly once a form has five inputs — you end up with five near-identical input listeners. Collapse them into one config map keyed by field name, then reuse firstError for whichever field changed. The minLen and noSpaces rules below follow the same factory shape as maxLen, so nothing about the chain mechanism changes; only the wiring becomes declarative.

// Map every controlled field to its ordered rule list in one place.
type FieldRules = Record<string, Rule[]>;

const rules: FieldRules = {
  email: [required, isEmail, maxLen(254)],
  username: [required, minLen(3), maxLen(32), noSpaces],
  password: [required, minLen(12)],
};

const form = document.querySelector<HTMLFormElement>("#signup")!;

// One handler validates whichever field changed, reusing firstError verbatim.
function validateField(field: HTMLInputElement): boolean {
  const chain = rules[field.name];
  if (!chain) return true; // Field has no custom chain; leave native state alone.
  field.setCustomValidity(firstError(field.value, chain) ?? "");
  return field.validationMessage === "";
}

// Event delegation: one listener on the form covers every input inside it.
form.addEventListener("input", (event) => {
  const target = event.target;
  if (target instanceof HTMLInputElement) validateField(target);
});

form.addEventListener("submit", (event) => {
  // Re-run every chain so an untouched-but-empty field is gated too.
  for (const name of Object.keys(rules)) {
    const field = form.elements.namedItem(name) as HTMLInputElement;
    validateField(field);
  }
  if (!form.reportValidity()) event.preventDefault();
});

Delegating a single input listener to the form rather than binding one per field means dynamically inserted inputs are covered automatically, and the submit loop closes the gap where a field the user never focused would otherwise skip validation entirely. Each field still short-circuits independently — the map only shares the plumbing, not the message priority.

Preserving Native ValidityState Flags Alongside Custom Rules

setCustomValidity does not run in isolation. A field can be invalid for two independent reasons at once: a native constraint attribute (required, type="email", maxlength, pattern) and your custom string. The validity object exposes both — validity.valueMissing, validity.typeMismatch, validity.tooLong, and finally validity.customError — and validationMessage surfaces whichever the browser considers active. When both a native constraint and a custom message are present, the browser reports the native one, so a custom rule can be silently masked.

The clean rule is to own one axis or the other, not both. If your chain already includes a required predicate, drop the required attribute from that input so the empty-field message comes from your ordered chain and not from the browser’s non-configurable default. Conversely, if you want the native message, do not duplicate that check in the chain. The snippet below reads the flags to decide whether a custom message is even needed, which keeps the two systems from fighting over validationMessage.

function firstErrorRespectingNative(field: HTMLInputElement, chain: Rule[]): void {
  // Clear any prior custom string so native flags can re-evaluate cleanly.
  field.setCustomValidity("");
  // If a native constraint already fails, defer to the browser's message.
  if (!field.validity.valid && !field.validity.customError) return;
  field.setCustomValidity(firstError(field.value, chain) ?? "");
}

Clearing the custom string first is load-bearing: customError stays true until you reset it, so a stale custom message would keep validity.valid false and hide a native flag you actually wanted. Read the flag you care about explicitly rather than parsing validationMessage, whose text is localized and varies across browsers and OS versions.

Frequently Asked Questions

How is short-circuiting different from throwing on the first error?

Throwing unwinds the stack and forces a try/catch at every call site, which is heavy for expected outcomes. Returning the first message from find keeps the flow as ordinary control flow, so the chain stays a pure function you can test in isolation.

Can I short-circuit a chain that includes an async server check?

Not in this synchronous shape. Run the synchronous chain first and only fire the network request if it returns null, which also avoids wasting a round trip on a value that is already malformed. See the async server checks guide for cancellation and ordering.

Does this work with schema validators like Zod?

Yes. A Zod schema stops at the first issue per field by default when you read error.issues[0], giving the same one-message result. If you already model your form with Zod schema validation, take the first issue's message and feed it to setCustomValidity exactly as above.

Should I use Array.prototype.find or a for loop for the chain?

Both short-circuit — find and a for loop with an early return both stop at the first match, so neither evaluates rules past the failure. Prefer find because it reads as an expression and keeps firstError a pure function. The one caveat is that the current find version calls the failing predicate twice (once to locate it, once to read its message); if a rule is expensive, cache the result with a single loop that returns rule(value) the moment it is non-null.

← Back to Synchronous Validation Patterns