Validating Common Input Types

Most validation advice is generic — required fields, patterns, async checks — but most validation bugs are specific. They live in the particular rules of particular kinds of data: the card number that is 15 digits for one brand and 19 for another, the postcode that is five digits in one country and seven alphanumeric characters in the next, the password composition rule that rejects strong passphrases, the file whose extension lies about its content, the birthday that is off by one in the evening in California, the name that contains an apostrophe. This section is a field-by-field guide to validating the input types that appear on almost every product, written for frontend engineers, design-system maintainers and accessibility specialists who need rules that are strict where strictness protects users and permissive everywhere else. Every pattern builds on the site’s canonical baseline — <form novalidate> plus a manual reportValidity() driven by the Constraint Validation API — and every rule reports its verdict through setCustomValidity(), so the browser’s own focus management and error announcement stay in charge.

The section is organised by kind of data rather than by technique: password validation patterns, payment card validation, phone and address validation, file upload validation, date and time validation and identity text field validation. Each topic has focused guides for the specific problems practitioners search for — a Luhn check, an IBAN checksum, a strength meter, a magic-byte sniff, an age calculation — with runnable TypeScript, verification steps and tests.

Input types covered in this section Six cards naming the kinds of input covered in this section and the characteristic validation problem of each. Passwords length over composition, breach screening, accessible strength feedback Payment cards brand-specific lengths, Luhn and mod-97 checksums, expiry month rules Phone and address country-first rules, E.164 storage, postcodes that vary by country File uploads content sniffing, size and count limits, image dimensions, drag and drop Dates and times calendar dates versus instants, time zones, age and business hours Identity text permissive names, strict usernames, URL parsing, email typo suggestions
Each kind of data has one signature problem that generic validation misses; the topics are organised around those problems.

Architecture: Where Input-Type Rules Sit in the Validation Stack

Type-specific rules are one layer in a larger stack, and putting them in the right layer is most of the design work. Declarative attributes (required, minlength, type="email") handle what the platform already knows. Type-specific validators — Luhn, postcode tables, date arithmetic — encode knowledge about the data that no attribute can express, and they run synchronously on every relevant event. Asynchronous checks — breach screening, username availability, address lookup — sit after the synchronous layer and only run on values that already passed it. The server repeats everything and adds the checks only it can do: uniqueness, deliverability, authorisation. Errors from every layer converge on the same place: the field’s custom validity and its described-by error container.

Where input-type validation sits Five layers from declarative HTML attributes through type-specific synchronous validators and asynchronous checks to server enforcement, all converging on the field's custom validity. Declarative attributes required, minlength, type, inputmode, autocomplete Type-specific validators Luhn, mod 97, postcode tables, date arithmetic, name rules Asynchronous checks breach screening, availability, address lookup — debounced Server enforcement same rules from a shared module, plus uniqueness and deliverability One error surface setCustomValidity + aria-describedby + reportValidity
Type-specific rules are the second layer: they run synchronously after the attributes and gate the more expensive asynchronous checks.

The trade-offs between putting a rule in each layer are consistent across input types:

Layer Strength Weakness Example rules
Attributes Zero script, works before JS loads Only the rules the platform knows required, minlength="12", type="email"
Type validators Precise, instant, specific messages Must be kept in sync with the server Luhn, IBAN, postcode by country
Async checks Knowledge only a service has Latency, races, outages Breached passwords, username taken
Server Authoritative, tamper-proof Feedback arrives late Uniqueness, file malware scan

The most important architectural decision in this section is the asymmetry of risk that runs through every topic. Rejecting a valid value costs a customer; accepting a slightly wrong one usually costs a retry. So the patterns reject what is certainly wrong (a failed checksum, an impossible date, a file whose bytes contradict its type), warn or suggest where something is probably wrong (a likely email typo, an address line longer than the courier prints), and accept everything else.

Core Constraint Model for Typed Fields

Every type-specific validator in this section plugs into the same small API surface. Knowing exactly how these properties behave for text, date and file inputs removes a whole class of bugs.

API Type Default Triggered by Notes for typed fields
setCustomValidity(msg) (string) => void "" Your code The single channel for Luhn, postcode, date and file verdicts
validity.customError boolean false setCustomValidity True while any custom message is set
validity.badInput boolean false Browser parsing Partially typed native dates and times
validity.typeMismatch boolean false type="email", type="url" Email grammar; URL scheme required
validity.tooShort boolean false User edits only Never set for script-assigned values
validity.rangeUnderflow / rangeOverflow boolean false min / max Dates and times; ISO format only
inputmode attribute none Mobile keyboards numeric for cards and codes, url, tel, email
autocomplete token none Autofill cc-number, postal-code, tel, bday, new-password
reportValidity() () => boolean Your submit handler Focuses and announces the first failure

Canonical Implementation: A Typed Field Registry

Rather than wiring every field by hand, the patterns in this section compose well into a small registry: each field declares its rule, the events that re-run it, and whether the rule is asynchronous. One submit handler then validates everything through the same path.

type SyncRule = (el: HTMLInputElement, form: HTMLFormElement) => string;
type AsyncRule = (el: HTMLInputElement, signal: AbortSignal) => Promise<string>;

interface FieldSpec {
  selector: string;
  rule: SyncRule;
  asyncRule?: AsyncRule;          // runs after the sync rule passes
  events?: Array<"input" | "change" | "blur">;
  debounceMs?: number;
}

export function registerFields(form: HTMLFormElement, specs: FieldSpec[]): () => Promise<boolean> {
  const pending = new Map<HTMLInputElement, Promise<void>>();
  const controllers = new Map<HTMLInputElement, AbortController>();
  const timers = new Map<HTMLInputElement, number>();

  const runSync = (el: HTMLInputElement, spec: FieldSpec) => {
    el.setCustomValidity("");                     // expose native flags to the rule
    const error = spec.rule(el, form);
    el.setCustomValidity(error);
    return error;
  };

  for (const spec of specs) {
    const el = form.querySelector<HTMLInputElement>(spec.selector);
    if (!el) throw new Error(`No field for ${spec.selector}`);
    for (const type of spec.events ?? ["input", "blur"]) {
      el.addEventListener(type, () => {
        if (runSync(el, spec) || !spec.asyncRule) return;
        controllers.get(el)?.abort();
        window.clearTimeout(timers.get(el));
        el.setCustomValidity("Checking…");        // never submit an unchecked value
        const ctrl = new AbortController();
        controllers.set(el, ctrl);
        pending.set(el, new Promise((resolve) => {
          timers.set(el, window.setTimeout(async () => {
            try {
              const error = await spec.asyncRule!(el, ctrl.signal);
              if (!ctrl.signal.aborted) el.setCustomValidity(error);
            } catch {
              if (!ctrl.signal.aborted) el.setCustomValidity(""); // fail open; server re-checks
            }
            resolve();
          }, spec.debounceMs ?? 400));
        }));
      });
    }
  }

  // The canonical submit path: sync rules, await async ones, then reportValidity().
  return async () => {
    for (const spec of specs) runSync(form.querySelector<HTMLInputElement>(spec.selector)!, spec);
    await Promise.all(pending.values());
    return form.reportValidity();
  };
}

// Usage: rules come from the topic guides.
const form = document.querySelector<HTMLFormElement>("#checkout")!;
const validateAll = registerFields(form, [
  { selector: "#cc-number", rule: (el) => cardNumberError(el.value) },
  { selector: "#cc-exp", rule: (el) => expiryError(el.value) },
  { selector: "#postcode", rule: (el, f) => postcodeError(el.value, (f.elements.namedItem("country") as HTMLSelectElement).value, el.required), events: ["input", "blur", "change"] },
  { selector: "#new-password", rule: (el) => el.validity.tooShort ? "Password must be at least 12 characters." : "", asyncRule: breachRule, debounceMs: 500 },
]);

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (await validateAll()) form.submit();
});

The registry encodes three habits that recur in every topic: clear the custom error before running a rule so native flags are readable; set a “Checking…” custom error while an asynchronous rule is pending so a fast submit cannot slip past it; and await pending checks before the single reportValidity() call. The async machinery is the same debounce-and-abort pattern covered in cancelling stale requests with AbortController.

Submit with a pending asynchronous rule The user submits while the password breach check is still pending; the submit handler runs synchronous rules, waits for the pending check, and only then calls reportValidity. User Submit handler Field registry Breach check press Enter run every sync rule await pending promise not found → custom validity cleared all checks settled reportValidity() focuses any failure
Awaiting pending checks before reportValidity() is what keeps asynchronous rules from being bypassed by a fast Enter key.

Accessible UX Integration for Typed Fields

Type-specific validation creates more opportunities to help users — and more ways to overwhelm them — than generic required-field checks. Four integration rules apply across every topic in this section.

Announce verdicts, not keystrokes. Checklists, strength meters, brand detection and availability checks all update as the user types. None of them should use a live region that fires on every keystroke; that interrupts the screen reader’s character echo, which is how users know what they typed. Update visuals live, reference the information from aria-describedby so it is read on focus, and announce a single summary on blur, on a typing pause, or when an asynchronous check completes. The live password requirements checklist and accessible password strength meter show both halves of this rule.

Name the thing and the fix. “Invalid” is never enough. Card errors name the brand and its length; file errors name the file and the rule; date errors write the allowed date out in words; postcode errors give a local example. This is what WCAG 3.3.1 and 3.3.3 require, and it is the difference between a user fixing the problem and abandoning the form.

Put suggestions in descriptions, not errors. Email typo suggestions, carrier line-length warnings and “this is a HEIC photo, we’ll convert it” notices are advice. They belong in the field’s description with a real button where an action is possible, never in setCustomValidity(), which would block submission.

Use the right autocomplete token everywhere. Almost every field in this section has a standard token — cc-number, cc-exp, postal-code, tel, bday-day, new-password, username, url. Autofill is the single most effective error-prevention mechanism available, and WCAG 1.3.5 requires the tokens anyway.

A typed field with every integration point A checkout form showing a card number field with detected brand text, an expiry field with an example hint and an error, and annotations for each accessibility integration point. Payment Card number 3782 822463 10005 1 ✓ Detected card type: American Express Expiry date (MM/YY) 13/29 2 ✗ Enter the expiry date as MM/YY, for example 04/29. Security code •••• 3 4 digits on the front of the card Pay 1 Brand text is announced once, when it changes, never per digit 2 The error repeats the example from the hint so the fix is obvious 3 Hints change with the detected brand; autocomplete cc-csc enables fill 4 reportValidity on submit focuses the first failing field
Hints are visible and described, errors name the fix, brand detection is announced once, and autocomplete tokens let the browser fill everything.

Cross-Browser Strategy and Progressive Fallbacks

Most of the platform features this section relies on are now available everywhere, but a few differ enough between engines to shape the code.

Feature Quirk Strategy
type="date" / type="time" Chromium lets users type impossible dates and sets badInput; Firefox and Safari clamp segments Check badInput first; never parse value yourself
createImageBitmap(file) Safari decodes HEIC; Chromium and Firefox reject it Catch the rejection and say which formats work
DataTransfer() constructor Missing before Safari 14.1 Keep your own File[] and submit with FormData
Intl.Segmenter Firefox before 125 lacks it Fall back to [...value].length (code points)
URL.canParse Recent in all engines try { new URL(v) } catch {}
Temporal Arriving engine by engine Polyfill, loaded only on pages with time-zone rules
beforeinput on Android IMEs send composition text, not single keys Filter by characters in event.data, never by key codes

Feature-detect rather than sniffing browsers, and load the heavy helpers — phone metadata, password strength dictionaries, the Temporal polyfill — lazily, on first focus of the field that needs them. That keeps the rarely used parts of this section off the critical rendering path.

const supportsSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl;
const graphemeCount = supportsSegmenter
  ? (s: string) => [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)].length
  : (s: string) => [...s].length;
/* Style only after interaction where supported; fall back to the attribute you set yourself. */
@supports selector(:user-invalid) {
  input:user-invalid { border-color: var(--error-border); }
}
input[aria-invalid="true"] { border-color: var(--error-border); }

For styling, :user-invalid is supported in current versions of all three engines and avoids painting required fields red before any interaction; the older fallback is an [aria-invalid="true"] selector you set yourself on blur or submit, as described in styling invalid inputs with :user-invalid.

Framework Integration Patterns

Every validator in this section is a pure function from a value (plus context) to a message string, which makes them trivially portable into any framework. The integration job is only to call them at the right moments and to bridge the result into the framework’s error state and the native custom validity.

// React: a hook that keeps native validity and React state in sync
import { useCallback, useRef, useState } from "react";

export function useTypedField(rule: (v: string) => string) {
  const ref = useRef<HTMLInputElement>(null);
  const [error, setError] = useState("");
  const validate = useCallback(() => {
    const el = ref.current;
    if (!el) return true;
    const message = rule(el.value);
    el.setCustomValidity(message);           // native layer stays authoritative
    setError(message);                       // React renders the described-by message
    return !message;
  }, [rule]);
  return { ref, error, validate, onBlur: validate };
}

// Usage: const card = useTypedField(cardNumberError);
// <input ref={card.ref} onBlur={card.onBlur} aria-invalid={!!card.error} aria-describedby="cc-err" />
// Vue: the same bridge as a composable
import { ref, type Ref } from "vue";

export function useTypedField(rule: (v: string) => string, el: Ref<HTMLInputElement | null>) {
  const error = ref("");
  function validate(): boolean {
    if (!el.value) return true;
    error.value = rule(el.value.value);
    el.value.setCustomValidity(error.value);
    return !error.value;
  }
  return { error, validate };
}

With form libraries, register the same functions as field validators: React Hook Form’s validate option, VeeValidate rules, Angular ValidatorFns, or refinements in a Zod schema shared with the server. The library-specific wiring is covered in framework integration patterns, and the schema route — which also gives you server enforcement for free — is covered in schema-based validation with Zod.

Automated Testing Strategy

Typed-field validators are ideal test subjects: they are pure, their inputs are well understood, and their failures are expensive in production. The testing strategy has three layers.

Unit tests with real-world fixtures. Table-driven Vitest suites pin each rule to concrete values, including the awkward ones that historically broke naive rules: Amex numbers, ZIP+4 codes, Eircodes, names with apostrophes and diacritics, leap-day birthdays, HEIC files. Inject the clock (now) into every date rule so tests never depend on the day they run. Generated inputs with property-based testing are especially powerful for checksums, where “every single-digit change is detected” is a property you can prove across thousands of cases — see property-based testing validators with fast-check.

import { describe, it, expect } from "vitest";
import fc from "fast-check";
import { luhnValid, luhnCheckDigit } from "./luhn";

describe("Luhn", () => {
  it("detects every single-digit substitution", () => {
    fc.assert(fc.property(fc.stringMatching(/^\d{15}$/), fc.nat(15), fc.integer({ min: 1, max: 9 }), (prefix, pos, delta) => {
      const valid = prefix + luhnCheckDigit(prefix);
      const i = pos % valid.length;
      const changed = valid.slice(0, i) + ((Number(valid[i]) + delta) % 10) + valid.slice(i + 1);
      return !luhnValid(changed);
    }));
  });
});

Integration tests in a real browser. Playwright exercises what unit tests cannot: tooShort (only set by real typing), caret positions after formatting, autofill ordering, file inputs via setInputFiles, and time zones via timezoneId. Assert on user-visible outcomes — the message text, aria-invalid, focus position — rather than on internal state.

Accessibility audits. Run axe-core on each form in its error state as well as its initial state; many problems (missing descriptions, colour-only checklists, unlabeled toggles) only appear after validation fires. The CI setup is covered in automating axe-core form audits in CI.

Testing layers for typed fields Unit tests with fixtures and generated inputs feed browser integration tests, which feed accessibility audits of error states, all gated in CI before deployment. Unit fixtures real-world values per rule Property tests checksums, lengths, dates Playwright typing, autofill, files, zones axe-core initial and error states CI gate all layers on every change
Pure validators get exhaustive unit and property tests; the browser layer tests only what needs a real browser.

Deciding Whether a Rule Should Block, Warn or Stay Silent

Every rule in this section passed through the same three questions before it was written, and running a proposed rule through them is the fastest way to avoid shipping the next “your name is invalid” bug. First: can the value be certainly wrong, by a check that has no false positives? A failed Luhn checksum, a 31 April, a file whose bytes say PNG when the rule requires PDF — these block. Second: can the value be probably wrong? A domain one edit away from Gmail, an address line longer than the courier prints, a password that appears in a breach corpus when the service is unreachable — these warn, suggest or defer to the server. Third: does the rule encode an assumption about people or places? “Two words”, “five-digit postcode”, “letters only”, “must have a state” — these are removed, or made conditional on the country or context that actually implies them.

Should a proposed rule block, warn or be dropped? A decision tree that classifies a proposed validation rule as blocking, advisory or dropped based on whether it has false positives and whether it assumes something about people or places. Does the rule assume something about people or places? yes Drop it, or make it conditional on country or context no Can a valid value ever fail it? no Block with a specific message yes Warn or suggest, never block
Only rules with no false positives block; probable problems become advice; assumptions about people or places are dropped or made conditional.

The framework has a useful side effect in code review. A reviewer who sees a new setCustomValidity() call can ask one question — “what valid value does this reject?” — and if the author can name one, the rule belongs in the advisory tier instead.

Enforcing the Same Rules on the Server

The browser is where these rules are felt; the server is where they are enforced. The only sustainable way to keep both in agreement is to write each rule once, in a module with no DOM dependencies, and import it on both sides. The validators in this section are deliberately shaped for that: they take strings and plain objects, return message strings, and inject their clock and configuration. On the server, compose them into a schema so a request is validated in one call and the result comes back as field-keyed messages the client can map straight onto its inputs.

// shared/checkout-schema.ts — imported by the browser bundle and the API route
import { z } from "zod";
import { cardNumberError, expiryError } from "./payment-rules";
import { postcodeError } from "./postcode-rules";

const rule = (fn: (v: string) => string) => (v: string, ctx: z.RefinementCtx) => {
  const message = fn(v);
  if (message) ctx.addIssue({ code: z.ZodIssueCode.custom, message });
};

export const checkoutSchema = z
  .object({
    cardnumber: z.string().superRefine(rule(cardNumberError)),
    "cc-exp": z.string().superRefine(rule((v) => expiryError(v))),
    country: z.string().length(2),
    postcode: z.string(),
  })
  .superRefine((data, ctx) => {
    const message = postcodeError(data.postcode, data.country, true);
    if (message) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["postcode"], message });
  });

Because the server produces the same messages the browser would have shown, a request that bypassed client validation — scripting disabled, an old cached bundle, a crafted request — still gets an error the user understands. The architecture is covered in depth in the server and full-stack validation section, including the response format in problem details for field errors.

Performance Budget for Typed-Field Validation

Type-specific rules are cheap individually, but a checkout form can easily accumulate a card formatter, a phone metadata bundle, a password dictionary and an image decoder, and the cost shows up as input latency on low-end phones. Hold the section’s patterns to a simple budget. Synchronous rules must finish well inside a frame — Luhn, mod 97, postcode tables and date comparisons take microseconds, so the budget only bites when a rule allocates heavily or rebuilds regular expressions per keystroke. Anything over a few kilobytes of data (phone metadata, strength dictionaries, the Temporal polyfill) loads lazily on first focus of the field that needs it. Anything that touches large binary data (file sniffing, image decoding) reads only the bytes it needs and decodes off the main thread. And no rule runs a network request per keystroke: debounce, abort and cache. Measured this way, a fully validated checkout adds no perceptible delay to typing, which is the bar every pattern here is written to meet.

Measuring Validation Friction in Production

Typed-field rules are the part of a form most likely to be wrong for some slice of users you never tested with, so measure them after release. Record, without the values themselves, which rule fired for which field and whether the user eventually submitted successfully. A rule that fires often and is followed by abandonment is a candidate for relaxing; a rule that fires often and is followed by an immediate fix is doing its job. Segment by country and device: a postcode rule that fires for 30% of Irish users, or a card formatter whose error rate doubles on Android, points straight at a bug. Keep the data aggregated and short-lived — the goal is to find unfair rules, not to track people — and feed every discovered edge case back into the fixture tables so it can never regress.

Implementation Checklist

Frequently Asked Questions

Why organise validation by input type rather than by technique?

Because most real bugs are type-specific — card lengths by brand, postcodes by country, time zones for dates, content sniffing for files. Grouping by data puts all the knowledge a field needs in one place.

Should type-specific rules block submission or only warn?

Block only what is certainly wrong, such as a failed checksum or an impossible date. Warn or suggest where something is probably wrong, like an email domain typo, and accept everything else.

Do these patterns work with React, Vue and Angular?

Yes. Every validator is a pure function from a value to a message, so it can be used as a React Hook Form validate function, a VeeValidate rule, an Angular ValidatorFn or a Zod refinement, while still setting the native custom validity.

Is client-side validation of these fields enough?

No. It improves the experience and catches typos early, but the server must repeat every rule from a shared module and add the checks only it can perform, such as uniqueness and deliverability.

← Back to Home

Explore This Section