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 patterns

Two 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

PatternMatchesNote
.any character except a newlineadd the s flag to include newlines
\d / \Da digit / not a digitASCII 0-9 only
\w / \W[A-Za-z0-9_] / the restnot unicode aware, "é" is a \W
\s / \Swhitespace / not whitespaceincludes tabs and newlines
[aeiou]any one of those charactersa set
[^aeiou]any character not in the set^ inside brackets means "not"
[a-z0-9]ranges inside a seta hyphen at either end is literal
\ba word boundaryzero width, matches a position
\p{L}any unicode letterneeds 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'));        // false

Alternation, and why the non-capturing group matters.

Flags

FlagNameWhat it changes
gglobalfind every match; required by matchAll and regex replaceAll
iignore case/cat/i matches Cat and CAT
mmultiline^ and $ also match at line breaks
sdotAll. also matches a newline
uunicodecode point aware, enables \u{...} and \p{...}
ystickymatch only exactly at lastIndex, never search forward
dhasIndicesadds match.indices with start and end of every group
vunicode setsnewer 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 state

The 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 away

That 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 kept

A 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 escaped

Anything 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.

  1. 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.
  2. Anchor it Decide immediately whether you are searching inside text or validating a whole value. Validation gets ^ and $.
  3. 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.
  4. Prefer a negated class to a lazy dot [^"]* cannot cross the delimiter, so it needs no backtracking and cannot explode.
  5. 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 boolean
re.test(str), and drop the g flag
one match with groups
str.match(re) or re.exec(str)
every match with groups
[...str.matchAll(re)], g required
every matched string
str.match(/re/g)
transform each match
str.replace(re, (m, ...g) => ...)
literal text swap
str.replaceAll(text, next), no regex
user supplied term
escape it, then new RegExp(escaped, "gi")
named groups
(?<name>...), read m.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.

Jamie Zawinski, 1997

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 g flag the regex keeps a lastIndex. The first test matches at 0 and sets lastIndex to 1; the second starts searching from index 1, finds nothing, returns false and resets lastIndex to 0. Drop the g when 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/) — matchAll requires the g flag, and throws without it. That was a deliberate design choice: a "match all" that returned one result would be the same confusing asymmetry match already has. replaceAll throws on the opposite mistake, a regex without g.
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 g flagged regex object and getting alternating results from test.
  • 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 \w and \d to be unicode aware. They are ASCII only.

Takeaways

  • Use a literal for known patterns, and new RegExp only 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.
  • g adds lastIndex state to the regex object, which is the source of the strangest regex bugs.
  • matchAll plus a replacement function covers almost every real extraction and rewrite job.