Regular expressions in JavaScript — patterns, not string comparisons

A regular expression describes the SHAPE a string should match, not a specific string to compare against — that shift, from "is this string equal to X" to "does this string match this pattern," is what makes regex genuinely more powerful than string methods for validation and extraction, and genuinely easier to get subtly wrong.

Intermediate

4 min read

The core idea: matching a pattern, not a literal string

const hasDigit = /\d/;        // matches ANY single digit character, not a specific one
"abc123".match(hasDigit);       // finds "1" — the FIRST digit found anywhere in the string
 
const email = /^[\w.]+@[\w.]+\.\w+$/;  // matches the SHAPE of an email address, not one specific email
email.test("ada@example.com");  // true
email.test("not-an-email");      // false

A regular expression (regex) is a pattern describing what a matching string should look like\d means "any digit," + means "one or more of the preceding thing," ^/$ anchor to the start/end of the string — rather than comparing against one specific, literal value. This is what makes regex the right tool for "does this look like a valid email" or "extract every phone number from this text," where a plain === comparison against a fixed string is structurally the wrong tool entirely.

Common patterns worth knowing on sight

/\d+/        // one or more digits
/\s+/         // one or more whitespace characters
/[a-zA-Z]+/    // one or more letters
/^\s*$/         // a string that's ENTIRELY whitespace (or empty)
/colou?r/        // "color" OR "colour" — the ? makes the preceding character OPTIONAL
/\bcat\b/         // the WORD "cat" specifically — \b is a word boundary, won't match "category"

A handful of building blocks — character classes (\d, \s, \w, or a custom [abc]), quantifiers (+ one-or-more, * zero-or-more, ? optional), and anchors (^/$ for string boundaries, \b for word boundaries) — combine to express a surprising range of real patterns. \b specifically matters for a common, real mistake: /cat/ matches inside "category" too, since it's just checking for the substring anywhere; /\bcat\b/ matches only the standalone word "cat," not "cat" as a substring of something longer.

test(), match(), and replace(): the three most common real methods

/\d+/.test("abc123");                     // true — just a yes/no check
"abc123".match(/\d+/);                     // ["123", index: 3, ...] — the actual matched text and position
"2026-08-31".replace(/-/g, "/");             // "2026/08/31" — the `g` (global) flag replaces EVERY match, not just the first

test() returns a boolean — genuinely useful for validation ("does this match at all"). match() returns the actual matched substring (and its position) — useful for extraction. replace() substitutes matched text — and critically, without the g (global) flag, it only replaces the first match, a real, common source of "why didn't this replace all the dashes" confusion when the flag is forgotten.

Capture groups: extracting specific PARTS of a match, not just the whole thing

const dateMatch = "2026-08-31".match(/(\d{4})-(\d{2})-(\d{2})/);
dateMatch[0]; // "2026-08-31" — the FULL match
dateMatch[1]; // "2026" — the FIRST capture group (year)
dateMatch[2]; // "08" — the SECOND capture group (month)
dateMatch[3]; // "31" — the THIRD capture group (day)
 
// Named groups — more readable than numeric indices
const named = "2026-08-31".match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
named.groups.year; // "2026"

Parentheses (...) in a pattern create a capture group — a specific sub-portion of the overall match that gets extracted separately, accessible by numeric index (match[1], match[2]) or, more readably, by name using (?<name>...) syntax. This is the real mechanism behind extracting structured pieces out of a larger match — pulling the year, month, and day separately out of a matched date string, rather than getting back only the full matched text with no way to isolate its parts.

A real, genuine performance trap: catastrophic backtracking

// A pattern that LOOKS reasonable but can take EXPONENTIALLY long on certain inputs
const dangerous = /^(a+)+$/;
dangerous.test("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaX");
// This can take SECONDS or MINUTES to fail — the regex engine tries an
// exponential number of ways to group the a's before giving up

Certain regex patterns — typically nested quantifiers like (a+)+ — can cause the regex engine to try an exponential number of ways to match, taking dramatically longer on certain (often adversarial) input than the pattern's apparent simplicity suggests, a real, documented vulnerability class called ReDoS (Regular Expression Denial of Service) when the pattern is applied to untrusted user input. This connects directly to this platform's Web Security domain's rate-limiting lesson — a regex like this, applied to attacker-controlled input on a public endpoint, is a genuine, real way to make a server hang, not just a theoretical performance curiosity.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What's the fundamental difference between a regex pattern and a plain string comparison like `===`?

2. Why does `/cat/.test("category")` return true, and what fixes it if only the standalone word is intended?

3. What is 'catastrophic backtracking,' and why does it matter for regex applied to untrusted input?