Skip to content
Chapter 3Lesson 6

Regex: the modern flavor

The modern JavaScript regular-expression surface, and the judgment of when to drop the regex for a parser.

Two production bugs, both from a regex. One looks like a textbook validator yet rejects most of the world’s names; the other hand-rolls what a single import would have done.

const isValidUsername = (input: string) => /^[a-zA-Z0-9]+$/.test(input);
isValidUsername('Smith'); // true
isValidUsername('Müller'); // false
isValidUsername('José'); // false
isValidUsername('小明'); // false

This shipped to a sign-up form and by morning had locked users out of their own accounts. The cause: [a-zA-Z0-9] matches only ASCII, but a letter today means any Unicode letter, so three of these four names fail.

const EMAIL_RE = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
export const subscribe = async (formData: FormData) => {
const email = String(formData.get('email'));
if (!EMAIL_RE.test(email)) {
return { error: 'Invalid email' };
}
// ... persist subscriber
};

This regex is hard to maintain and wrong both ways: it rejects valid addresses (quoted local-parts, internationalized domains, the .museum TLD) and accepts garbage like user@a.b. It also buries the real contract, a deliverable email address, behind a wall of escapes. One Zod schema does the job correctly and carries a localized error message and a maintained validator with it.

This lesson covers the modern regex flavor and the point where a regex stops being the right tool.

JavaScript gives you two ways to write a regex. Default to the literal; use the constructor only when the pattern is dynamic.

const hexColor = /^#[\da-f]{6}$/i;

Slashes wrap the pattern; flags follow the closing slash. It compiles once at parse time, every editor highlights it, and it fits any pattern fixed when you write it, which is almost all of them.

Each flag turns on one behavior.

// g — required for .matchAll and for .replaceAll(regex, ...)
// i — case-insensitive; the daily reach for human input
// m — ^ and $ match line boundaries, not just string boundaries
// s — . matches newlines (dotAll); needed for patterns that span lines
// u — Unicode mode; full code-point matching, enables \p{...} escapes
// v — ES2024 Unicode sets mode; supersedes u, adds set ops and \p{RGI_Emoji}

Flags combine freely (gi, gim, gs), with one exception: Unicode mode (u) and Unicode sets mode (v) are mutually exclusive, so /pattern/uv is a syntax error. Default to u; reach for v only for set operations inside a character class or for matching emoji sequences.

When a regex captures structure, name the captures. A positional reference like match[1] breaks the moment you add or reorder a group; a named one does not. Older code uses the indexed form, so read it, but write the named form yourself.

const invoiceRe = /^INV-(?<year>\d{4})-(?<num>\d{4})$/;
const match = 'INV-2026-0042'.match(invoiceRe);
if (match?.groups) {
const { year, num } = match.groups;
// year: '2026', num: '0042'
}

(?<name>...) names a group inline; each name must be a valid identifier, unique within the pattern.

const invoiceRe = /^INV-(?<year>\d{4})-(?<num>\d{4})$/;
const match = 'INV-2026-0042'.match(invoiceRe);
if (match?.groups) {
const { year, num } = match.groups;
// year: '2026', num: '0042'
}

.match(re) returns a match object or null. On a match, .groups is an object keyed by your group names. The match?.groups guard covers both cases: optional chaining short-circuits when there’s no match.

const invoiceRe = /^INV-(?<year>\d{4})-(?<num>\d{4})$/;
const match = 'INV-2026-0042'.match(invoiceRe);
if (match?.groups) {
const { year, num } = match.groups;
// year: '2026', num: '0042'
}

TypeScript types .groups as { [key: string]: string } | undefined: it can’t tell which names the pattern used, so every key is just string. The guard narrows away the undefined, so the destructure gives string, not string | undefined. For tighter types, validate the values with Zod at the call site.

1 / 1

The indexed form, /^INV-(\d{4})-(\d{4})$/ read through match[1] and match[2], still works; recognize it in review. Backreferences split the same way: \k<year> named, \1 indexed.

One hazard: nested unbounded quantifiers like (a+)+, or .* before an alternation, can trigger ReDoS on hostile input, where backtracking explodes and one request pins a CPU core for seconds. Don’t nest quantifiers, and cap the length of any user-controlled string before the regex sees it.

The keystone habit, and the fix for the intro’s username bug.

const isLetters = (input: string) => /^[a-zA-Z]+$/u.test(input);
['Müller', '小明', 'José', 'Smith'].map(isLetters);
// → [false, false, false, true]

[a-zA-Z] is the 52 ASCII letters and nothing else, so accented Latin, CJK, Cyrillic, Arabic, and Devanagari all fall through. Three of four names are rejected.

The properties worth recognizing on sight:

  • \p{Letter} — any letter in any script; the default for “this looks like a name.”
  • \p{Number} — any numeric character, including digits, Roman numerals, and Arabic-Indic digits.
  • \p{White_Space} — any whitespace, including non-ASCII spaces.
  • \p{Emoji} — any emoji code point.
  • \p{Script=Latin}, \p{Script=Han}, \p{Script=Cyrillic}, and so on — a specific script, when you intentionally need one.

The rule: any regex over human-entered text should use \p{...}. Save [a-zA-Z] for data that is ASCII by contract, such as a hex token, a Base64 chunk, or a protocol identifier whose spec is itself ASCII.

The v flag adds one capability worth recognizing: set operations inside a character class.

// Letters that are also in ASCII — the explicit "Latin alphabet" form
const asciiLetter = /^[\p{Letter}&&\p{ASCII}]+$/v;
// Letters that are NOT ASCII — names with accents and non-Latin scripts
const nonAsciiLetter = /^[\p{Letter}--\p{ASCII}]+$/v;

&& is intersection, -- is difference, and a nested class is a union. You won’t reach for these often; recognize them inside a character class and don’t misread them as logical operators.

A regex is a value; these four methods run it against a string. One inconsistency between two of them causes most regex bugs.

pattern.test(string) answers “does this match?” with a boolean.

const isHexColor = /^#[\da-f]{6}$/i;
isHexColor.test('#ff8800'); // true
isHexColor.test('#FFFF'); // false
isHexColor.test('not a hex'); // false

Watch the g flag: a g regex carries a lastIndex cursor across calls, so each .test resumes where the last stopped and repeated calls on the same string give different answers. Never put g on a pattern you call .test on more than once.

string.match(pattern) is the inconsistent one. Without g it returns one match object, with the full match, .groups, .index, and captures. With g it returns a plain string[] of full matches and discards the groups and indices.

const re = /INV-(?<year>\d{4})-(?<num>\d{4})/;
'order INV-2026-0042'.match(re)?.groups;
// → { year: '2026', num: '0042' }
const reG = /INV-(?<year>\d{4})-(?<num>\d{4})/g;
'order INV-2026-0001 and INV-2026-0042'.match(reG);
// → ['INV-2026-0001', 'INV-2026-0042'] ← strings only, no groups

string.matchAll(pattern) recovers those lost groups. It requires g (a TypeError otherwise) and returns an iterator of full match objects, each with .groups, .index, and the captures.

const re = /INV-(?<year>\d{4})-(?<num>\d{4})/g;
for (const match of 'INV-2026-0001 and INV-2026-0042'.matchAll(re)) {
if (!match.groups) continue;
const { year, num } = match.groups;
// year: '2026', num: '0001' then year: '2026', num: '0042'
}

The iterator drops into a for...of loop, or into Array.from(text.matchAll(re)) for an array. To run a capturing regex repeatedly against one string, use .matchAll, not .match with g.

string.replaceAll(pattern, replacement) also requires g with a regex (TypeScript flags it, the runtime throws), so you can never replace just the first match by accident. The replacement is a string using $<name> for named groups or $1 for indexed, or a function (match, ...groups) => string when the result depends on the captures.

const numbered = 'INV-2026-0001 / INV-2026-0042'.replaceAll(
/INV-(?<year>\d{4})-(?<num>\d{4})/g,
(_full, _year, num) => `#${num}`,
);
// → '#0001 / #0042'

Now try the inconsistency yourself.

Both regexes match the same shape, but the methods do different things. Read carefully. Predict what this program prints, then press Check.

const text = 'order INV-2026-0001 and INV-2026-0042';
const re = /INV-(?<year>\d{4})-(?<num>\d{4})/g;
const result = text.match(re);
console.log(result?.[0]);
console.log(result?.[0]?.groups);

A lookaround tests what sits before or after the current position without consuming it.

// (?=...) positive lookahead — assert what follows
// (?!...) negative lookahead
// (?<=...) positive lookbehind — assert what precedes
// (?<!...) negative lookbehind
// "Numbers immediately followed by px, without capturing the px"
const pxValue = /\d+(?=px)/g;
'padding: 16px 24em 32px'.match(pxValue);
// → ['16', '32']

Capturing the context and slicing it off in code usually reads more clearly, so reach for a lookaround only when asserting without capturing is the whole point.

The harder judgment is knowing when not to write a regex. The intro’s email bug was a whole regex that should never have existed, because z.email() does the job. Two situations call for that restraint.

Situation 1: the input is a structured format. Email, URL, JSON, HTML, CSV, Markdown, and ISO dates each have a real specification and a parser one import away.

FormatParser (senior reach)
Emailz.email(): Zod 4 top-level format builder, lands in the forms unit
URLnew URL(input) (throws on invalid) or URL.canParse(input) for a boolean
JSONJSON.parse(input): covered in the JSON chapter
HTMLDOMParser in the browser, a real HTML parser on the server
CSVa CSV library, never regex
Markdowna Markdown parser
ISO dateTemporal.PlainDate.from(input): covered later, in the time chapter

You don’t need these parsers yet. You need the habit of pausing at a structured format and recognizing that a regex is the wrong tool before you type the pattern.

Situation 2: the regex is becoming unreadable. The threshold is the point where a reviewer can’t tell what the pattern matches in a single read. Past it, a small parser wins: a few .indexOf and .slice calls, or a real tokenizer when one is warranted.

If the text passes both checks, small, unstructured, and bounded, write modern regex: literal form, the u flag (or v for set operations or emoji), named groups, \p{Letter} over [a-zA-Z], and .matchAll over .match with g.

Now apply the rule. The validateContactInput PR below hand-rolls a regex twice where a parser belongs, and each regex carries a bug that makes the case.

Review this PR for a teammate. The function is supposed to accept either an email or a URL and tell the caller which. Two regex-versus-parser bugs to flag — leave a comment on each. Click any line to leave a review comment, then press Submit review.

src/validate-contact.ts
type ContactKind = 'email' | 'url' | null;
export const validateContactInput = (input: string): ContactKind => {
const emailRe = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
if (emailRe.test(input)) {
return 'email';
}
const urlRe = /^https?:\/\/.+\..+$/;
if (urlRe.test(input)) {
return 'url';
}
return null;
};