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.
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.
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.
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.
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.
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.
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.
Related Guides
- Password Validation Patterns — length rules, checklists, strength meters and breach screening.
- Payment Card Validation — card numbers, expiry dates, security codes and IBANs.
- Phone and Address Validation — international phone numbers, postcodes and autofilled addresses.
- File Upload Validation — types, sizes, dimensions and drag and drop.
- Date and Time Validation — calendar dates, instants, ages and business hours.
- Identity Text Field Validation — names, usernames, URLs and email addresses.