Unicode-Aware Name Field Validation

How do you validate a name field without rejecting Siobhán, Nguyễn, O’Brien, José María García-López, 李小龍 or someone with a single name? Almost every restriction that looks reasonable — letters only, A to Z, at least two words, no apostrophes, a maximum of 20 characters — rejects real people. This recipe validates names by what they can never contain rather than by what they must: it normalises to NFC, requires at least one Unicode letter, limits length by user-perceived characters with Intl.Segmenter, rejects control and invisible formatting characters, and reports through setCustomValidity() in the site’s standard Constraint Validation API flow.

When to Use Permissive Name Validation

Use it for every field that holds a human name: full name, given name, family name, display name, cardholder name, emergency contact. The permissive approach is correct because:

  • There is no global name format. Names can be one word or six, start with a lowercase particle (van der Berg), include apostrophes and hyphens, use any script, and mix scripts.
  • Rejection is costly and personal. Being told your own name is “invalid” is an exclusion users remember.
  • Strictness buys nothing. A name field cannot be validated against reality; a fake name made of letters passes any rule.

Strict character sets belong on identifiers such as usernames, covered in username validation rules and reserved names. Legal names on payment and identity documents may have issuer-specific constraints, but those belong on the server where the issuer’s rules are known, not in a generic front-end pattern.

Real names that naive rules reject Cards showing real name forms alongside the naive validation rule each one breaks. Siobhán O'Brien breaks "A–Z only" and "no apostrophes" José María García-López breaks "no hyphens" and "two words exactly" Nguyễn Văn An breaks "ASCII only"; combining marks 李小龍 breaks "Latin letters" and "min 4 characters" Madonna breaks "first and last name required" van der Berg breaks "must start with a capital"
Each rule looked sensible to someone; each one turns away real people, which is why the recipe rejects only what can never be part of a name.

Minimal Working Name Validator

const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const graphemeLength = (s: string): number => {
  let n = 0;
  for (const _ of segmenter.segment(s)) n++;
  return n;
};

const MAX_GRAPHEMES = 200;

/** Characters that can never be part of a name: controls and invisible formatting, except joiners. */
const FORBIDDEN = /[\p{Cc}\p{Co}\p{Cn}]|[\p{Cf}--[‌‍]]/v;

export function normaliseName(raw: string): string {
  return raw.normalize("NFC").replace(/\s+/gu, " ").trim();
}

export function nameError(raw: string, label = "full name"): string {
  const value = normaliseName(raw);
  if (value === "") return `Enter your ${label}.`;
  if (graphemeLength(value) > MAX_GRAPHEMES) return `${label[0].toUpperCase()}${label.slice(1)} must be ${MAX_GRAPHEMES} characters or fewer.`;
  if (!/\p{L}/u.test(value)) return `${label[0].toUpperCase()}${label.slice(1)} must include at least one letter.`;
  if (FORBIDDEN.test(value)) return `${label[0].toUpperCase()}${label.slice(1)} contains characters we can't store. Remove any symbols copied from elsewhere.`;
  return "";
}

// Wiring
const form = document.querySelector<HTMLFormElement>("#details")!;
const name = form.querySelector<HTMLInputElement>("#fullname")!;

name.addEventListener("blur", () => {
  name.value = normaliseName(name.value);            // show the stored form
  name.setCustomValidity(nameError(name.value));
});
name.addEventListener("input", () => {
  if (name.validity.customError) name.setCustomValidity(nameError(name.value));
});
form.addEventListener("submit", (event) => {
  name.setCustomValidity(nameError(name.value));
  if (!form.checkValidity()) {
    event.preventDefault();
    form.reportValidity();
  }
});

The v flag enables set subtraction in character classes, which is how \p{Cf} (format characters) is allowed except for zero-width joiner and non-joiner — both are legitimate in names written in scripts such as Persian and in some emoji sequences. Where the v flag is unavailable, use the u flag and an explicit replace of and before testing \p{Cf}.

Permissive name validation steps A name is normalised to NFC with whitespace collapsed, checked for emptiness, measured in graphemes, checked for at least one letter and finally scanned for control and invisible characters. NFC + collapse spaces "José María " → "José María" Not empty required message ≤ 200 graphemes Intl.Segmenter count ≥ 1 letter \p{L} in any script No controls Cc, Co, Cn, Cf except joiners
Every step rejects something that can never be a name; nothing rejects a name for its script, punctuation or number of words.

Name Validator Option Reference

Option Type Default Purpose
Normalisation normalize("NFC") NFC Composes “e + ◌́” into “é” so storage and comparison are stable
Whitespace collapse + trim on Removes accidental doubles and pasted trailing spaces
MAX_GRAPHEMES number 200 Generous limit counted as users see characters
Letter requirement \p{L} at least one Rejects 123 or !!! without restricting scripts
Forbidden classes Cc, Co, Cn, Cf minus ZWJ/ZWNJ on Removes invisible and control characters
maxlength attribute number 400 Code-unit safety net; the grapheme rule is the real limit
autocomplete token name Also given-name, family-name for split fields

Why NFC and not NFKC for names? NFKC folds compatibility characters — it turns the ligature “fi” into “fi” and superscripts into digits — which is right for usernames, where lookalikes are a risk, but can alter the way someone has deliberately written their name. NFC only composes equivalent sequences, so the stored name looks exactly like the typed one.

Length of the same names in different units A table comparing the length of example names measured in UTF-16 code units, code points and graphemes. UTF-16 units Code points Graphemes Zoë (composed) 3 3 3 Zoë (decomposed) 4 4 3 Nguyễn 6 6 6 👩🏽‍🔬 Ada 11 8 5
Only grapheme counts match what users see; code-unit limits truncate names in some scripts and emoji mid-character.

Verification Steps

import { describe, it, expect } from "vitest";
import { nameError, normaliseName } from "./name";

describe("nameError", () => {
  it.each(["Siobhán O'Brien", "José María García-López", "Nguyễn Văn An", "李小龍", "Madonna", "van der Berg", "Ólafur Arnalds", "N'Golo Kanté"])(
    "accepts %s", (n) => expect(nameError(n)).toBe(""),
  );
  it("rejects names with no letters", () => expect(nameError("12345")).toMatch(/at least one letter/));
  it("rejects zero-width spaces", () => expect(nameError("Ada​Lovelace")).toMatch(/can't store/));
  it("keeps zero-width joiners", () => expect(nameError("می‌خواهم")).toBe(""));
  it("composes decomposed characters", () => expect(normaliseName("Zoë")).toBe("Zoë"));
});

Edge Cases and Failure Modes

Emoji in names. Some people put emoji in display names. Whether to allow them is a product decision, not a validation truth; the recipe allows them because they are letters’ neighbours in \p{So} and not forbidden. If your context is formal (legal name, cardholder name), reject \p{Extended_Pictographic} with a message that says so plainly.

Right-to-left override characters. U+202E (RIGHT-TO-LEFT OVERRIDE) is in \p{Cf} and is used to disguise text, including file names in phishing. The forbidden class removes it. Legitimate right-to-left names in Arabic or Hebrew need no override characters; the browser’s bidi algorithm handles them.

Splitting full names. Never derive “first name” and “last name” by splitting on the space. If you need a given name for greetings, ask for it separately as “What should we call you?” with autocomplete="nickname" or given-name.

Uppercase and title-casing. Do not “fix” capitalisation. McDonald, van der Berg, DeShawn and d'Artagnan all break under automatic title-casing. Store and display the name as typed.

Messages for the Rare Rejection

With a permissive validator, rejections are rare, which means each one is likely to be a surprise to the user and deserves a message that assumes good faith. “Name contains characters we can’t store” is phrased as a limitation of the system rather than a fault in the name, and it points at the most common real cause: invisible characters pasted from a document, a spreadsheet or a chat app. Never echo back the forbidden character itself, because it is invisible and the message would look like it names nothing; if you want to be more precise, name its position (“after ‘Ada’”) instead.

Server-Side Storage of Names

The server should apply the same normalisation and forbidden-character rule, so a name accepted by the browser is never rejected later and never stored differently from how it was shown. Store names in UTF-8 columns with a four-byte-capable collation (utf8mb4 in MySQL), or emoji and some CJK characters will be truncated or rejected by the database itself — a failure that looks like a validation bug to the user. Set database length limits in characters, not bytes, and comfortably above the front-end grapheme limit, because one grapheme can be many code points. When names flow into systems with narrower character sets — shipping labels, legacy banking — transliterate at that boundary and keep the original, following the same approach the phone and address validation topic recommends for addresses.

Asking for Names Respectfully

Validation is only part of respectful name handling; the form’s structure matters as much. A single “Full name” field is the most inclusive default, because it lets people write their name in their own order and form. If a process genuinely needs parts — a formal greeting, a legal document — label them by role rather than position (“Given names” and “Family name”, not “First” and “Last”), make the family name optional for people who do not have one, and explain why each part is needed in hint text. Avoid prefix dropdowns (Mr, Mrs, Ms) unless they are truly required; if you include them, add “Mx” and “None”, and never make them required. These choices remove whole categories of validation failure before any code runs, which is the approach behind writing clear inline error message copy: the best error message is the one the design made unnecessary.

Frequently Asked Questions

What regex should I use to validate names?

Avoid allow-list regexes for names. Require at least one Unicode letter with \p{L} and the u flag, and reject only control, private-use and invisible formatting characters. Everything else, including apostrophes, hyphens and any script, should be accepted.

How should I limit the length of a name field?

Count graphemes — characters as users see them — with Intl.Segmenter, and set a generous limit such as 200. JavaScript's length counts UTF-16 code units and misjudges accented and emoji characters.

Should names be normalised?

Yes, with NFC, which composes equivalent character sequences so storage and comparison are stable without changing how the name looks. Avoid NFKC for names; it can alter deliberately written characters.

Should I require both a first and a last name?

No. Many people have a single name, and many cultures order names differently. Use one full-name field, or label parts by role and make the family name optional.

← Back to Identity Text Field Validation