Regular Expressions
Regular expressions are a small language for describing text patterns. JavaScript's regex engine gained a lot since ES5: named groups, lookbehind, Unicode properties, matchAll, new flags and RegExp.escape.
You will learn
- How to create a regex: literals vs the
RegExpconstructor, and escaping user input. - Character classes, quantifiers, anchors, groups and backreferences.
- Named groups and lookbehind ES2018, Unicode property escapes.
- The flags
g i m s u y d v, and what each changes. test,exec,match,matchAllES2020,replaceandsplit, plus the statefullastIndextrap.
Creating a regular expression
⚠️ Pitfall: never put raw user input into new RegExp(input). Characters like ., + and ( have special meanings, and a malicious pattern can freeze your program (catastrophic backtracking). Use RegExp.escape ES2025. (Notice that it even escapes a leading letter, as \x61, so that the result is safe in any position.)
Pattern syntax
| Pattern | Matches |
|---|---|
. | any character except line breaks (unless the s flag is set) |
\d \w \s / \D \W \S | a digit, a word character [A-Za-z0-9_], whitespace / their opposites |
[aeiou] [^,] [a-z] | one character from a set / not in a set / a range |
\p{L}, \p{Lu}, \p{Emoji} | Unicode properties: any letter, an uppercase letter, an emoji (needs u or v) |
* + ? {2} {2,5} {2,} | 0+, 1+, 0 or 1, exactly 2, 2 to 5, at least 2 times |
*? +? ?? | lazy versions: match as little as possible |
^ $ \b \B | start, end, word boundary, not a boundary |
(…) (?:…) (?<name>…) | capturing group, non-capturing group, named group ES2018 |
\1 \k<name> | backreference to a group's text |
a|b | alternation |
(?=…) (?!…) | lookahead: followed by / not followed by |
(?<=…) (?<!…) | lookbehind: preceded by / not preceded by ES2018 |
The first of the last two examples is a classic surprise. The goal was "numbers not followed by ' qty'". 100 is followed by " qty", so the lookahead fails, and the engine backtracks: it tries the shorter 10, which is followed by 0, not " qty". So 10 matches, and you get #0. Adding \d to the negative lookahead, (?!\d| qty), forbids stopping in the middle of a number. Always test lookarounds on real data.
Named groups ES2018
Named groups make patterns self-documenting, and they show up on match.groups. In replace, refer to them with $<name>.
Flags
| Flag | Name | Effect |
|---|---|---|
g | global | find all matches; makes exec/test stateful via lastIndex |
i | ignoreCase | case-insensitive |
m | multiline | ^ and $ match at every line start and end |
s | dotAll ES2018 | . also matches line breaks |
u | unicode ES2015 | works on code points, enables \p{…}, stricter syntax |
y | sticky ES2015 | match only at exactly lastIndex (useful for tokenizers) |
d | hasIndices ES2022 | adds match.indices with the start/end of each group |
v | unicodeSets ES2024 | an upgraded u: set operations in classes like [\p{L}--[a-z]] |
Methods
| Method | Returns |
|---|---|
re.test(str) | true / false |
re.exec(str) | one match array (with groups and index), or null |
str.match(re) | without g: like exec. With g: an array of the matched strings only, or null |
str.matchAll(re) ES2020 | an iterator of full match objects; requires g |
str.replace(re, x) / replaceAll | a new string; x may be a string with $1/$<name>/$& or a function |
str.split(re), str.search(re) | an array of parts / the index of the first match |
The lastIndex trap
A regex with g or y remembers where its last match ended, in lastIndex. Reusing the same regex object with test or exec continues from there, which leads to alternating results.
❌ A shared global regex in test()
✅ No g flag for yes/no checks
More examples
1. Validating a simple postcode
2. Extracting all prices from text
3. Slugifying a title with Unicode classes
4. Highlighting search terms safely
5. A tiny tokenizer with the sticky flag
Recap
- Use literals for fixed patterns and
new RegExpfor dynamic ones; escape user input withRegExp.escape. - Quantifiers are greedy unless you add
?. Use\bfor word boundaries, and lookarounds to check context without consuming it. - Named groups give readable
match.groups, and$<name>in replacements. - Flags:
g i m s u y d v. Useuorvfor Unicode-correct matching. - Use
matchAllfor full details of every match. Never share agregex fortest()checks.
Exercises
Exercise 1 🟢 easy build it
Write a regex that tests for a 4-digit year from 1900 to 2099.
Show solution
The anchors ^ and $ make the whole string match, not just a part of it.
Exercise 2 🟢 easy predict the output
What do "a1b22c333".match(/\d+/g) and "a1b22c333".match(/\d+/)[0] return?
Show solution
[ '1', '22', '333' ] and '1'. With g, match returns every match; without it, only the first (plus group details).
Exercise 3 🟢 easy fix the bug
This should replace every dot with a dash, but it replaces everything. Fix it.
Show solution
An unescaped . means "any character". For a literal text replacement, replaceAll with a string avoids regex altogether.
Exercise 4 🟢 easy explain why
Why does /^\p{L}+$/.test("Émile") throw or misbehave without a flag, and what fixes it?
Show solution
Without u (or v), \p is just an escaped "p", so the pattern means "one or more p's followed by {L}", and it fails. Adding the u flag enables Unicode property escapes: /^\p{L}+$/u.
Exercise 5 🟡 medium build it
Using named groups, turn "Herbert, Frank" into "Frank Herbert" with a single replace.
Show solution
[^,]+ stops at the comma, and $<name> refers to named groups in the replacement string.
Exercise 6 🟡 medium predict the output
What does this print?
Show solution
true 2 true 3 false. Each test continues from lastIndex: it finds the "o" at index 1, then the one at index 2, then finds nothing and resets lastIndex to 0.
Exercise 7 🟡 medium build it · playground
Create playground/parse-orders.js exporting parseOrders(text), which reads lines like ORD-0001 | Ana | 2x b1, 1x b7 and returns { id, customer, items: [{ qty, book }] } objects, skipping lines that don't match.
Show solution
Lazy +? keeps the customer name from swallowing the trailing spaces. flatMap with [] drops the non-matching lines (Chapter 8).
Exercise 8 🟡 medium refactor it
Replace this exec loop with matchAll.
Show solution
matchAll returns an iterator of full match objects, without manual lastIndex bookkeeping.
Exercise 9 🔴 hard build it
Format an integer with thousands separators using a single replace with lookarounds: 1234567 → "1,234,567".
Show solution
It inserts a comma at every non-boundary position that is followed by a multiple of three digits up to the end. In real code, Intl.NumberFormat (Chapter 6) is the right tool.
Exercise 10 🔴 hard mini-project · playground
Create playground/validate.js exporting a validators object (email-ish, ISBN-13 with or without dashes, and a coupon code like SAVE-10 or SAVE-25), plus check(field, value), which returns { ok, value } with the normalized value (lower-cased email, ISBN without dashes, upper-cased coupon).
Show solution
Patterns without g are safe to share for test(). (?:-?\d){10} allows optional dashes between digits, and each validator pairs its pattern with a normalizer.