Identity Text Field Validation

The fields that identify a person — their name, username, email address and website — are the ones where validation most often insults the user. “Please enter a valid name” shown to someone called O’Brien, Nguyễn, or Zoë. A username rule that allows admin and support but blocks jürgen. An email regex copied from a forum that rejects first.last+news@example.co.uk. A URL field that refuses example.com because it lacks https://. Each of these is a small rule written without the full range of real input in mind, and each silently tells a user that the product was not built for them. This topic sets out how to validate identity text fields firmly where it matters — uniqueness, impersonation, deliverability — and permissively everywhere else, on top of the site’s standard <form novalidate> and Constraint Validation API baseline.

The principle is to separate three kinds of rule that usually get tangled together: syntax (can this string be an email address at all?), policy (may this username be used on our platform?), and likelihood (did the user probably mean gmail.com rather than gmial.com?). Syntax failures block, policy failures block with an explanation, and likelihood issues never block — they suggest.

Three kinds of identity field rule A layered view separating syntax rules that block, policy rules that block with explanations, and likelihood hints that only suggest. Syntax can this be an email, URL or username at all? — blocks Policy reserved words, uniqueness, confusable characters — blocks with a reason Likelihood probable typos such as gmial.com — suggests, never blocks
Keeping the three kinds of rule apart is what lets a form be strict about impersonation and still accept every real name.

Prerequisites for Identity Field Validation

Requirement Minimum version Why it is needed
TypeScript 5.0+ Typed rule tables
Unicode property escapes \p{L} All evergreen browsers Letter classes in any script
String.prototype.normalize All browsers NFC/NFKC before comparing or storing
URL constructor + URL.canParse canParse: Chrome 120, Firefox 115, Safari 17 Parsing and normalising web addresses
Intl.Segmenter Chrome 87, Firefox 125, Safari 14.1 Counting user-perceived characters for length limits
type="email", type="url" All browsers Native syntax checks and mobile keyboards
Server-side uniqueness endpoint Username availability, rate limited

Identity Field API Reference

API Type Effect Notes
type="email" attribute typeMismatch for impossible addresses Deliberately permissive; allows a@b
type="url" attribute typeMismatch without a scheme Rejects example.com; often too strict
autocomplete="username" / name / email / url tokens Autofill and purpose Required by WCAG 1.3.5
spellcheck="false" / autocapitalize="none" attributes Stops mobile “corrections” Essential on username and email
value.normalize("NFKC") string Compatibility normalisation Use for usernames and comparisons
new Intl.Segmenter(undefined, { granularity: "grapheme" }) Intl.Segmenter Grapheme iteration Length limits that match what users see
/\p{L}/u RegExp Any letter in any script Replaces [A-Za-z] in name rules
URL.canParse(s) (string) => boolean Parse test without throwing Fallback: try { new URL(s) }

Step-by-Step Implementation

1. Choose input types and attributes per field

<form id="profile" novalidate>
  <label for="fullname">Full name</label>
  <input id="fullname" name="name" autocomplete="name" maxlength="200" required>

  <label for="username">Username</label>
  <input id="username" name="username" autocomplete="username" required
         spellcheck="false" autocapitalize="none" aria-describedby="username-hint username-err">
  <p id="username-hint" class="hint">3–30 characters: letters, numbers, dots and underscores.</p>
  <p id="username-err" class="field-error" hidden></p>

  <label for="email">Email address</label>
  <input id="email" name="email" type="email" autocomplete="email" required
         spellcheck="false" autocapitalize="none" aria-describedby="email-err email-suggest">
  <p id="email-err" class="field-error" hidden></p>
  <p id="email-suggest" class="hint" hidden></p>

  <label for="website">Website <span class="optional">(optional)</span></label>
  <input id="website" name="website" type="text" inputmode="url" autocomplete="url" spellcheck="false">
</form>

The website field uses type="text" with inputmode="url" rather than type="url": the URL keyboard on mobile, without the native rule that rejects addresses typed without https://. The full reasoning is in URL input validation with the URL constructor.

2. Keep names permissive

const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const length = (s: string) => [...graphemes.segment(s)].length;

export function nameError(raw: string): string {
  const value = raw.normalize("NFC").trim();
  if (value === "") return "Enter your full name.";
  if (length(value) > 200) return "Name must be 200 characters or fewer.";
  // Reject only what cannot be a name: no letters at all, or control characters.
  if (!/\p{L}/u.test(value)) return "Name must include at least one letter.";
  if (/[\p{Cc}\p{Cf}]/u.test(value.replace(//g, ""))) return "Name contains characters we can't store.";
  return "";
}

There is no allow-list of characters. Apostrophes, hyphens, spaces, diacritics, non-Latin scripts and single-word names are all valid. Unicode-aware name field validation walks through why each “obvious” restriction fails real people.

3. Make usernames strict, normalised and unique

const RESERVED = new Set(["admin", "administrator", "root", "support", "help", "security", "api", "www", "mail", "about", "settings", "login", "signup"]);

export function usernameError(raw: string): string {
  const value = raw.normalize("NFKC").toLowerCase();
  if (value.length < 3 || value.length > 30) return "Username must be 3 to 30 characters.";
  if (!/^[\p{Ll}\p{Nd}._]+$/u.test(value)) return "Use only letters, numbers, dots and underscores.";
  if (/^[._]|[._]$|[._]{2}/.test(value)) return "Dots and underscores can't be first, last or next to each other.";
  if (RESERVED.has(value.replace(/[._]/g, ""))) return "That username is reserved. Choose another.";
  return "";
}

Usernames are identifiers other people will type and see in URLs, so they are the one identity field where a tight character set is justified — and where normalisation (NFKC plus lower-casing) prevents two accounts that look identical. Availability is then an asynchronous server check. The full rule set, including confusable characters, is username validation rules and reserved names.

4. Let the browser judge email syntax, then suggest typo fixes

export function emailError(input: HTMLInputElement): string {
  input.setCustomValidity("");
  if (input.validity.valueMissing) return "Enter your email address.";
  if (input.validity.typeMismatch) return "Enter an email address like name@example.com.";
  if (!/\.[^.]+$/.test(input.value.split("@")[1] ?? "")) return "Enter an email address like name@example.com.";
  return "";
}

The native type="email" check follows the HTML specification’s grammar, which is intentionally practical. The only addition here rejects dotless domains such as name@localhost, which are syntactically valid but never what a public form wants. Typos in the domain are handled by a suggestion, not an error — see suggesting email domain typo corrections — and deliverability is proven only by sending a confirmation email. The native attributes themselves are covered in email input validation attributes.

5. Wire every field through one submit path

const form = document.querySelector<HTMLFormElement>("#profile")!;
const rules: Array<[string, (el: HTMLInputElement) => string]> = [
  ["#fullname", (el) => nameError(el.value)],
  ["#username", (el) => usernameError(el.value)],
  ["#email", emailError],
  ["#website", (el) => websiteError(el.value)],
];

form.addEventListener("submit", (event) => {
  for (const [sel, rule] of rules) {
    const el = form.querySelector<HTMLInputElement>(sel)!;
    el.setCustomValidity(rule(el));
  }
  if (!form.checkValidity()) {
    event.preventDefault();
    form.reportValidity();
  }
});
Identity fields and their strictness A table of identity fields showing the character policy, normalisation and whether uniqueness is checked for each. Characters Normalise Unique Full name any letters, marks, spaces NFC, trim ✗ No Username lowercase letters, digits . _ NFKC + lowercase ✓ Yes Email HTML email grammar trim, lowercase domain per product Website anything URL can parse add https:// if missing ✗ No
Only the username is both strict and unique; names are deliberately permissive, and emails lean on the browser's grammar plus a confirmation email.

State Management and Edge Cases

Identity fields interact with each other and with the server more than most fields. The username availability check is asynchronous and needs the same debounce-and-abort discipline as any asynchronous server check; the email field may carry a pending suggestion that the user has not acted on; and a display name may be pre-filled from the email’s local part, which then must not overwrite what the user typed.

Username field states The username field moves from empty to locally checked, then to checking availability on the server, ending as available or taken, with edits returning it to local checking. empty local check checking availability available taken or invalid input rules pass (debounced) 404 not found 200 exists edit rule fails edit
Local rules run instantly; only a locally valid username is sent for an availability check, and every edit aborts the previous check.
  • Case and normalisation for comparisons. Compare usernames and emails in their normalised form (NFKC, lower-cased) for uniqueness, but display them as the user typed them.
  • Editing an existing identity. When a signed-in user edits their username, the availability check must treat their current username as available to them; otherwise saving the profile without changing the username fails with “already taken”.
  • Trimming. Trim leading and trailing whitespace from all four fields — pasted values often carry a trailing space — but never collapse internal spaces in names.
  • Suggestions are state too. An email suggestion (“Did you mean ada@gmail.com?”) should disappear the moment the user edits the domain, and must not be announced repeatedly.

Accessibility Compliance for Identity Fields

WCAG 1.3.5 Identify Input Purpose applies to every field here — name, username, email, url are all standard tokens — and using them lets users fill the form from their browser profile. 3.3.1 and 3.3.3 require messages that say what is wrong and how to fix it: “Use only letters, numbers, dots and underscores” rather than “Invalid username”. 3.3.8 Accessible Authentication means usernames and emails used for sign-in must allow paste and autofill.

Hint text matters as much as error text for identity fields, because the rules are otherwise invisible. The username hint in the markup above states the length and the allowed characters before the user types anything; the email field needs no hint because its format is universally understood; the name field should have no restrictive hint at all, since any hint (“first and last name”) excludes someone. Keep hints short, visible and linked with aria-describedby so they are read with the label, and never rely on placeholders, which vanish as soon as typing starts and are frequently too low in contrast to read.

Suggestions need particular care. A “Did you mean …?” hint should be a real button inside the field’s description, announced once through a polite status region when it first appears, and applying it should move focus back to the email field with the corrected value. A suggestion that only exists visually, or that steals focus as the user tabs away, fails the users it is meant to help.

Common Gotchas and Debugging

A-to-Z name patterns. pattern="[A-Za-z ]+" rejects most of the world’s names.

<!-- Before -->
<input name="name" pattern="[A-Za-z ]+">
<!-- After: no pattern; script rejects only control characters and letterless input -->
<input name="name" autocomplete="name" maxlength="200">

Home-made email regexes. They either reject valid addresses (plus signs, subdomains, new top-level domains) or accept junk. Use type="email" and a confirmation email.

Case-sensitive usernames. Ada and ada as separate accounts invite impersonation. Store a normalised, lower-cased key with a unique index.

type="url" on “website” fields. It rejects example.com. Parse with URL after adding a scheme if one is missing.

Autocorrect and autocapitalise on mobile. Without autocapitalize="none" and spellcheck="false", phones turn ada.lovelace into Ada.lovelace or “correct” it to a dictionary word.

Testing Identity Fields With Real-World Fixtures

The failures described in this topic are invisible in a test suite that only uses John Smith and test@example.com. Keep a fixture list of real-shaped values that have historically been rejected by naive rules, and run every identity validator against it on every change. The list should include names with apostrophes, hyphens, diacritics, non-Latin scripts, single-word names and very long names; emails with plus-addressing, subdomains, new top-level domains and uppercase letters; usernames that only differ by case or by confusable characters; and website values with and without schemes, with paths, and with internationalised domains.

import { describe, it, expect } from "vitest";

const VALID_NAMES = ["Siobhán O'Brien", "José María García-López", "Nguyễn Văn An", "李小龍", "Madonna", "Zoë Saldaña", "Jean-Luc Picard", "Ólafur Arnalds"];
const VALID_EMAILS = ["first.last+news@example.co.uk", "ADA@EXAMPLE.COM", "user@mail.example.photography", "o'reilly@example.ie"];

describe("identity fixtures", () => {
  it.each(VALID_NAMES)("accepts the name %s", (n) => expect(nameError(n)).toBe(""));
  it.each(VALID_EMAILS)("accepts the email %s", (e) => {
    const input = Object.assign(document.createElement("input"), { type: "email", value: e });
    expect(emailError(input)).toBe("");
  });
  it("treats usernames that differ only by case as the same key", () => {
    expect("Ada".normalize("NFKC").toLowerCase()).toBe("ada");
  });
});

When a support ticket reports a rejected value, the fix is to add that value to the fixture list first, watch the test fail, then relax the rule. The fixture list becomes a record of every real person the form once turned away, which is a powerful argument in code review against re-tightening a rule later. The broader approach is covered in unit testing validation logic.

Storing Identity Values Safely

How you store these values determines whether validation stays correct over time. Store names exactly as entered (after NFC normalisation and trimming) and never derive other identity data from them — splitting a full name into “first” and “last” by the space character is wrong for many cultures. Store usernames twice: the display form the user chose and a canonical key (NFKC, lower-cased, confusables mapped) with a unique index, so uniqueness is enforced by the database rather than by a race-prone “check then insert”. Store email addresses with the domain lower-cased but the local part as typed, because a small number of mail systems treat the local part as case-sensitive, and mark an address as verified only after a confirmation link is followed. Store website URLs in their parsed, normalised form (https://example.com/ rather than example.com) so that rendering them as links later needs no further guessing — and render them with rel="nofollow ugc" because they are user-provided.

Impersonation and Confusable Characters

Permissive name fields and strict username fields exist for the same reason: the risk is different. A display name is shown next to other information and is rarely the only thing that identifies someone, so letting people write their name in any script costs nothing. A username or handle is the identity in URLs, mentions and search, so two handles that look the same to a human are a real impersonation risk — paypal written with a Cyrillic а, or admin with a Greek ο. NFKC normalisation folds compatibility forms such as full-width letters, but it does not fold characters from different scripts that merely look alike. For that you need a “skeleton” comparison based on the Unicode confusables data: map each character to its prototype, and treat two usernames as the same if their skeletons match.

In practice most products take a simpler route that removes the problem at the source: restrict usernames to one script per account (Latin letters and digits is the common choice), which makes cross-script confusables impossible, and apply a skeleton check only to display names that are shown in trust-sensitive places, such as the sender of a payment request. Whichever you choose, run it on the server as part of the uniqueness check, and phrase the rejection neutrally — “That username is too similar to an existing one” — without revealing which account it resembles.

Display name versus username policy Two columns contrasting the permissive policy for display names with the strict, unique and confusable-aware policy for usernames. Display name • shown beside other context • any script, spaces, punctuation ✓ nobody is turned away • not unique; duplicates are fine Username / handle • the identity in URLs and mentions • one script, lowercase, digits . _ ✓ unique on a normalised key ✗ confusables checked on the server
The stricter the field's role as an identifier, the tighter its character set and the stronger its uniqueness check.

Deriving and Pre-Filling Identity Values

Forms often try to save typing by deriving one identity field from another: suggesting a username from the email’s local part, pre-filling a display name from the email, or splitting a full name into first and last. Derivation is helpful as a suggestion and harmful as an overwrite. Suggest a username only while the username field is still empty and untouched, and stop updating it the moment the user types in it; never split names by spaces, because many cultures put the family name first, use several family names, or have only one name. And a derived value must pass the same validation as a typed one: a username suggested from ada.lovelace+news@example.com must go through usernameError, which rejects the plus sign, so the suggestion should be sanitised first rather than offered in a form that will immediately fail.

const username = document.querySelector<HTMLInputElement>("#username")!;
const email = document.querySelector<HTMLInputElement>("#email")!;
let userTouched = false;
username.addEventListener("input", () => (userTouched = true), { once: true });

email.addEventListener("blur", () => {
  if (userTouched || username.value) return;
  const local = email.value.split("@")[0]?.normalize("NFKC").toLowerCase().replace(/[^\p{Ll}\p{Nd}._]/gu, "") ?? "";
  const suggestion = local.replace(/^[._]+|[._]+$/g, "").slice(0, 30);
  if (usernameError(suggestion) === "") username.value = suggestion;   // only offer what would pass
});

Browser Compatibility Matrix

Feature Chromium Firefox Safari Fallback
\p{L} in RegExp Yes Yes Yes
Intl.Segmenter 87+ 125+ 14.1+ [...s].length (code points)
URL.canParse 120+ 115+ 17+ try { new URL(s) } catch {}
type="email" grammar HTML spec HTML spec HTML spec
Internationalised email (ü@…) Partial Partial Partial Accept on server; browsers vary

Frequently Asked Questions

What characters should a name field allow?

Any letters in any script, plus spaces, apostrophes, hyphens, periods and combining marks. Reject only input with no letters at all or with control characters. There is no reliable character allow-list for human names.

Should I validate email addresses with a regex?

Use type="email", which applies the HTML specification's practical grammar, optionally add a check for a dot in the domain, and confirm the address by sending an email. Complex regexes reject valid addresses without proving any are real.

Why should usernames be normalised?

Without NFKC normalisation and case folding, visually identical usernames can belong to different accounts, which enables impersonation. Store a normalised key with a unique index and display the user's original form.

Should a website field use type url?

Usually not. type="url" rejects addresses typed without a scheme, such as example.com. Use a text input with inputmode="url", add https:// when missing, and parse with the URL constructor.

← Back to Validating Common Input Types

Explore This Section