Regular expressions

Write the small regular expressions that are worth writing, and recognise the ones that are not.

28of 32
≈ 11Min read
9Parts

Two ways to make one

literal or constructor
const re = /\d{4}/;                    // literal — compiled once
const re = new RegExp("\\d{4}", "g");   // from a string: every backslash doubles

// build one safely from user input
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const find = new RegExp(escape(query), "gi");

The pieces you will actually use

PatternMatches
.Any character except a newline
\d \w \sDigit, word character, whitespace
\D \W \SThe opposite of each
[abc] [^abc] [a-z]A set, a negated set, a range
* + ?Zero or more, one or more, optional
{3} {2,} {2,5}Exactly, at least, between
^ $Start and end of the string (or of a line with m)
\bA word boundary
(…)A capturing group
(?:…)A group that does not capture
(?<name>…)A named group
a|bEither

Flags

the five worth knowing
/x/g    // global — find all, not just the first
/x/i    // case-insensitive
/x/m    // ^ and $ match at line breaks
/x/s    // . also matches a newline
/x/u    // full unicode — needed for \p{…} and for astral characters

Using one

five methods, three of them on String
re.test(str);                 // boolean — the one you want most often
str.match(/\d+/);             // the first match with its groups, or null
str.match(/\d+/g);            // ALL the matches as plain strings, or null
[...str.matchAll(/(\w+)=(\w+)/g)];   // every match WITH its groups
str.replace(/-/g, "+");
str.split(/\s*,\s*/);
A global regex is stateful

re.test(s) with the g flag advances re.lastIndex, so calling it twice on the same string alternates true and false. Never reuse a g regex with test — drop the flag, or reset lastIndex.

Groups

numbered and named
const m = "2026-09-21".match(/(\d{4})-(\d{2})-(\d{2})/);
m[0];   // "2026-09-21" — the whole match
m[1];   // "2026"

const { groups } = "2026-09-21".match(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/);
groups.y;   // "2026"

// groups in a replacement
"2026-09-21".replace(/(?<y>\d+)-(?<m>\d+)-(?<d>\d+)/, "$<d>/$<m>/$<y>");

Greedy and lazy

? after a quantifier makes it lazy. Most "why did my regex eat the whole line" questions are a greedy . that should have been .? — or should not have been . at all.

the difference that catches everyone
'<a><b>'.match(/<.+>/)[0];    // "<a><b>"  — greedy: as much as possible
'<a><b>'.match(/<.+?>/)[0];   // "<a>"     — lazy: as little as possible

A replacement function

when the new value depends on the match
"5 doors, 3 windows".replace(/(\d+) (\w+)/g, (m, n, word) =>
  `${Number(n) * 2} ${word}`
);
// "10 doors, 6 windows"

// a safe highlighter
const highlight = (text, q) => {
  const re = new RegExp(escape(q), "gi");
  return text.replace(re, (m) => `<mark>${m}</mark>`);   // escape the TEXT first!
};

What not to match with a regex

  • Email addresses. The valid grammar is far stranger than any regex people write. Use type="email" and send a confirmation message — that is the only real test.
  • HTML. Nested, optional and recoverable. Parse it: new DOMParser().parseFromString(html, "text/html").
  • URLs. new URL(str) in a try tells you whether it is valid and gives you the parts.
  • Dates. new Date(iso) and check isNaN.
  • JSON. JSON.parse in a try.
Catastrophic backtracking

A pattern with nested quantifiers — (a+)+b — can take exponential time on a string that nearly matches. If a regex comes from user input, or has nested + and *, that is a denial-of-service waiting to happen.

Keeping them readable

A regex is write-once and read-never unless you give it a name. Two named constants at the top of a file are worth more than any amount of cleverness inside them.

name it and comment it
// a four-digit year, a two-digit month, a two-digit day
const ISO_DATE = /^(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})$/;

// rather than this, inline, three times
if (/^(\d{4})-(\d{2})-(\d{2})$/.test(s)) { }

Practice