Regular Expressions
Mental model: A regex is a tiny program that walks a string one character at a time and backtracks when it guesses wrong. Read it out loud and you can debug it.
Level: intermediate · about 18 minutes
const literal = /\d{3}-\d{4}/; // compiled once, at parse time
const built = new RegExp('\\d{3}-\\d{4}'); // built from a string, so double the backslashes
console.log(literal.test('call 555-1234')); // true, a boolean
console.log(literal.exec('call 555-1234')); // ['555-1234', index: 5, ...]
console.log('call 555-1234'.match(literal)); // the same match object
console.log(String(built) === String(literal)); // true, identical patternsTwo ways to make the same pattern, and the three questions you can ask it.
A regular expression describes a shape that text might have. Use a literal (/pattern/flags) whenever the pattern is known while you are writing the code: it is checked when the file parses, and you do not have to escape backslashes twice. Use new RegExp(source, flags) only when part of the pattern arrives at runtime, such as a search term typed by a user.
Character classes: what can sit here
| Pattern | Matches | Note |
|---|---|---|
. | any character except a newline | add the s flag to include newlines |
\d / \D | a digit / not a digit | ASCII 0-9 only |
\w / \W | [A-Za-z0-9_] / the rest | not unicode aware, "é" is a \W |
\s / \S | whitespace / not whitespace | includes tabs and newlines |
[aeiou] | any one of those characters | a set |
[^aeiou] | any character not in the set | ^ inside brackets means "not" |
[a-z0-9] | ranges inside a set | a hyphen at either end is literal |
\b | a word boundary | zero width, matches a position |
\p{L} | any unicode letter | needs the u or v flag |
console.log(/[^0-9]/.test('42')); // false, all digits
console.log('cat cats'.match(/\bcat\b/g)); // ['cat'], the plural is skipped
console.log(/\w/.test('é')); // false, \w is ASCII only
console.log(/\p{L}/u.test('é')); // true, unicode property escape
console.log('a-b'.match(/[a-]/g)); // ['a', '-'], trailing hyphen is literal
Quantifiers, and the greedy default
? 0 or 1 colou?r
* 0 or more ab*c
+ 1 or more \d+
{3} exactly 3 \d{3}
{2,4} 2 to 4 \w{2,4}
{2,} 2 or more \w{2,}
add ? to any of them to make it lazy: +? *? {2,}?
const html = '<b>bold</b> and <i>italic</i>';
console.log(html.match(/<.+>/)[0]); // '<b>bold</b> and <i>italic</i>'
console.log(html.match(/<.+?>/)[0]); // '<b>', the lazy version stops early
console.log(html.match(/<[^>]+>/)[0]); // '<b>', better still: never cross a '>'
console.log('aaa'.match(/a{2}/)[0]); // 'aa'
console.log('color colour'.match(/colou?r/g)); // ['color', 'colour']
const s = 'key="a" other="b"';
console.log(s.match(/"(.*)"/)[1]);.* is greedy: it runs to the end of the string, then backtracks just far enough to let the final " match, so the capture swallows the middle quotes. "(.*?)" gives a, and "([^"]*)" is better again because it can never cross a quote in the first place.
Anchors: positions, not characters
console.log(/^abc$/.test('abc')); // true, the whole string
console.log(/^abc$/.test('xabc')); // false
const lines = 'one\ntwo\nthree';
console.log(lines.match(/^t\w+/g)); // null, ^ means the very start of the string
console.log(lines.match(/^t\w+/gm)); // ['two', 'three'], m moves ^ to line starts
console.log(/\bcat/.test('concat')); // false, no boundary before 'cat' there
Groups, named groups and alternation
const date = '2024-03-05';
// Numbered groups: readable for about a week.
const [, y, m, d] = date.match(/(\d{4})-(\d{2})-(\d{2})/);
console.log(y, m, d); // '2024' '03' '05'
// Named groups: readable forever.
const pattern = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const { year, month, day } = date.match(pattern).groups;
console.log(year, month, day); // '2024' '03' '05'
// Names work in the replacement string too.
console.log(date.replace(pattern, '$<day>/$<month>/$<year>')); // '05/03/2024'
console.log(/^(cat|dog)s?$/.test('dogs')); // true
console.log('cat'.match(/^(?:cat|dog)$/)); // ['cat'], no group 1 kept
// Alternation is first-match-wins, left to right.
console.log('sunday'.match(/sun|sunday/)[0]); // 'sun', the short branch won
console.log('sunday'.match(/sunday|sun/)[0]); // 'sunday', order it longest first
// A backreference: the same text twice.
console.log(/(\w+) \1/.test('the the')); // true
console.log(/(\w+) \1/.test('the cat')); // falseAlternation, and why the non-capturing group matters.
Flags
| Flag | Name | What it changes |
|---|---|---|
g | global | find every match; required by matchAll and regex replaceAll |
i | ignore case | /cat/i matches Cat and CAT |
m | multiline | ^ and $ also match at line breaks |
s | dotAll | . also matches a newline |
u | unicode | code point aware, enables \u{...} and \p{...} |
y | sticky | match only exactly at lastIndex, never search forward |
d | hasIndices | adds match.indices with start and end of every group |
v | unicode sets | newer u, adds set operations inside [...] |
console.log(/a.b/.test('a\nb')); // false
console.log(/a.b/s.test('a\nb')); // true, s lets the dot cross newlines
const withIndices = /(?<word>\w+)/d.exec('hi there');
console.log(withIndices.indices.groups.word); // [0, 2]
const sticky = /\d+/y;
sticky.lastIndex = 0;
console.log(sticky.test('12abc')); // true, matched at 0
console.log(sticky.test('12abc')); // false, lastIndex is 2 and 'a' is not a digit
const re = /a/g;
console.log(re.test('a'), re.lastIndex); // true 1
console.log(re.test('a'), re.lastIndex); // false 0, it searched from index 1
console.log(re.test('a')); // true again, and so on
const safe = /a/;
console.log(safe.test('a'), safe.test('a')); // true true, no g, no stateThe classic stateful-regex bug.
The four ways to use a pattern
match without g
'a1b2'.match(/(\w)(\d)/);
// ['a1', 'a', '1',
// index: 0, groups: undefined]
// one match, with groups
match with g
'a1b2'.match(/(\w)(\d)/g);
// ['a1', 'b2']
// every match, groups thrown awayThat asymmetry is why matchAll exists: it gives every match and keeps the groups, index and input for each one. Reach for matchAll whenever you need more than the matched text.
const log = 'x=1, y=22, z=333';
const pair = /(?<key>\w+)=(?<value>\d+)/g;
for (const m of log.matchAll(pair)) {
console.log(m.groups.key, m.groups.value, m.index);
}
console.log(Object.fromEntries(
[...log.matchAll(pair)].map((m) => [m.groups.key, Number(m.groups.value)])
));
// { x: 1, y: 22, z: 333 }
// matchAll needs the g flag, on purpose.
try { log.matchAll(/\d/); } catch (err) { console.log(err.constructor.name); } // TypeError
console.log('a-b-c'.replace(/-/g, '+')); // 'a+b+c'
console.log('a-b-c'.replaceAll('-', '+')); // 'a+b+c', no regex needed
const prices = 'tea 250, cake 400';
console.log(prices.replace(/\d+/g, (m) => (Number(m) / 100).toFixed(2)));
// 'tea 2.50, cake 4.00'
console.log('2024-03-05'.replace(
/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/,
(...args) => {
const groups = args.at(-1); // named groups arrive last
return `${groups.d}/${groups.m}/${groups.y}`;
}
)); // '05/03/2024'
console.log('a1b2c'.split(/(\d)/)); // ['a','1','b','2','c'], captures are keptA replacement function receives the match, then every group, then the offset.
Lookaround: assert without consuming
// (?=...) lookahead, (?!...) negative lookahead
console.log('100px 200em'.match(/\d+(?=px)/)[0]); // '100', 'px' is not consumed
console.log('100px 200em'.match(/\d+(?!px)/)[0]); // '10', careful: it matched a prefix
// (?<=...) lookbehind, (?<!...) negative lookbehind
console.log('£50 $60'.match(/(?<=£)\d+/)[0]); // '50'
console.log('£50 $60'.match(/(?<!£)\b\d+/)[0]); // '60'
// Thousands separators, the classic lookahead trick.
console.log('1234567'.replace(/\B(?=(\d{3})+(?!\d))/g, ',')); // '1,234,567'
Interactive visualiser: regex. Enable JavaScript to use it.
Escaping user input
const term = 'a.b';
console.log(new RegExp(term).test('axb')); // true, the dot matched 'x'
const escapeRegExp = (s) => s.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
console.log(escapeRegExp('a.b')); // 'a\.b'
console.log(new RegExp(escapeRegExp(term)).test('axb')); // false, correct
console.log(new RegExp(escapeRegExp(term)).test('a.b')); // true
// RegExp.escape is standard as of ES2025 but not everywhere yet.
const esc = typeof RegExp.escape === 'function' ? RegExp.escape : escapeRegExp;
console.log(esc('1+1') !== '1+1'); // true, the plus was escapedAnything a user types must be escaped before it becomes a pattern.
const evil = /^(a+)+b$/;
const input = 'a'.repeat(20); // no 'b', so it must fail
const t0 = Date.now();
console.log(evil.test(input)); // false
console.log('nested quantifier ms:', Date.now() - t0);
const safe = /^a+b$/; // one way to match, so no explosion
const t1 = Date.now();
console.log(safe.test('a'.repeat(20000)));
console.log('flat quantifier ms:', Date.now() - t1);Do not raise the repeat count. Each extra character doubles the work.
- Say the pattern in words first Write the sentence, then translate it clause by clause. "Four digits, a dash, two digits" is already the regex.
- Anchor it Decide immediately whether you are searching inside text or validating a whole value. Validation gets
^and$. - Name every group If you are going to read something out of the match, name it. Future you will not remember what group 3 was.
- Prefer a negated class to a lazy dot
[^"]*cannot cross the delimiter, so it needs no backtracking and cannot explode. - Test the failures, not the successes Every regex bug is an input you did not think of. Feed it the empty string, a very long string, and text that nearly matches.
just a booleanre.test(str), and drop thegflagone match with groupsstr.match(re)orre.exec(str)every match with groups[...str.matchAll(re)],grequiredevery matched stringstr.match(/re/g)transform each matchstr.replace(re, (m, ...g) => ...)literal text swapstr.replaceAll(text, next), no regexuser supplied term- escape it, then
new RegExp(escaped, "gi") named groups(?<name>...), readm.groups.name, replace with$<name>
Some people, when confronted with a problem, think "I know, I will use regular expressions." Now they have two problems.
Try it yourself
Pull structure out of text
const text = `
2024-03-05 10:00 INFO user=ada action=login
2024-03-05 10:04 WARN user=grace action=retry
2024-03-06 09:10 ERROR user=alan action=crash
`;
const line = /^(?<date>\d{4}-\d{2}-\d{2}) (?<time>\d{2}:\d{2}) +(?<level>\w+) +user=(?<user>\w+) +action=(?<action>\w+)$/gm;
const rows = [...text.matchAll(line)].map((m) => m.groups);
console.log(rows);
console.log(rows.filter((r) => r.level !== 'INFO').map((r) => r.user));
Add a group for an optional log level in brackets. Then rewrite the pattern with a negated class instead of the lazy dot and compare the results.
Replace with a function
const values = { name: 'Ada', count: 3 };
const filled = 'Hi {name}, you have {count} messages and {missing} bugs.'
.replace(/\{(\w+)\}/g, (whole, key) =>
Object.hasOwn(values, key) ? String(values[key]) : whole);
console.log(filled);
// Title-case every word, without a loop.
console.log('the quick brown fox'.replace(/\b\w/g, (c) => c.toUpperCase()));
// Mask all but the last four digits.
console.log('4111111111111111'.replace(/\d(?=\d{4})/g, '*'));
Make the template tag support a default value like {name|friend}. Then make an unknown key throw instead of resolving to an empty string.
Exercises
Parse a duration string
Write parseDuration(text) which turns "1h30m" into a number of seconds. The units are lower case h, m and s, each part is optional, and they must appear in that order. Return null for anything that does not match the whole string, including the empty string and a bare number.
Highlight a search term safely
Write escapeRegExp(text) which escapes every regex metacharacter so the result matches the original text literally. Then write highlight(text, term) which wraps every case insensitive occurrence of term in ** while keeping the original casing. An empty term returns the text unchanged.
Check yourself
- What does this log?
- true false — With the
gflag the regex keeps alastIndex. The firsttestmatches at 0 and setslastIndexto 1; the second starts searching from index 1, finds nothing, returnsfalseand resetslastIndexto 0. Drop thegwhen all you want is a boolean. - What does
"<a><b>".match(/<.+>/)[0]return? - '<a><b>' — Quantifiers are greedy:
.+takes everything it can, then gives back only enough for the final>to match. That leaves the whole string.<.+?>or, better,<[^>]+>gives"<a>". - Which call throws a TypeError?
'aa'.matchAll(/a/)—matchAllrequires thegflag, and throws without it. That was a deliberate design choice: a "match all" that returned one result would be the same confusing asymmetrymatchalready has.replaceAllthrows on the opposite mistake, a regex withoutg.- You need
.to match newlines as well. Which flag? s—s(dotAll) makes.match any character including line terminators.m(multiline) is the one people reach for by mistake: it only changes what^and$mean, moving them to line boundaries.
Common mistakes
- Forgetting
^and$in a validator, so"abc1234xyz"passes a four digit check. - Reusing a
gflagged regex object and getting alternating results fromtest. - Using a greedy
.*where the data has delimiters, and capturing far too much. - Building a pattern from user input without escaping it.
- Nesting quantifiers such as
(a+)+, which can hang the whole thread on a failing input. - Expecting
\wand\dto be unicode aware. They are ASCII only.
Takeaways
- Use a literal for known patterns, and
new RegExponly for runtime input, always escaped. - Anchor with
^and$when validating; leave them off when searching. - Quantifiers are greedy by default;
?makes them lazy, and a negated class is usually better than either. - Name your groups, read them from
match.groups, and use$<name>in replacements. gaddslastIndexstate to the regex object, which is the source of the strangest regex bugs.matchAllplus a replacement function covers almost every real extraction and rewrite job.