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.
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}.
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.
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("AdaLovelace")).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.
Related Guides
- Identity Text Field Validation — names alongside usernames, emails and URLs.
- Localizing Custom Validation Messages — messages for users of every language.
- Writing Clear Inline Error Message Copy — wording the rare name rejection kindly.
- Property-Based Testing Validators with fast-check — generating Unicode inputs to test the validator.