A regular expression — a pattern the engine walks character by character against your input, backtracking when a branch fails — earns its keep in exactly a few jobs, and quietly ruins your afternoon in a few others. Most production regex bugs aren't exotic: an unanchored validator accepting a substring, a greedy .* swallowing past the boundary you meant, or a user string that silently became regex syntax. Keep a small set of patterns you trust, and make their assumptions explicit. MDN's regex guide is the reference worth bookmarking.
The patterns that pull their weight
| Job | Pattern | Why it works |
|---|---|---|
| Trim surrounding whitespace | `^\s+ | \s+$withg` |
| A whole positive integer | ^[0-9]+$ | Anchors reject 12px |
| A simple slug | ^[a-z0-9]+(?:-[a-z0-9]+)*$ | No leading, trailing, or doubled dash |
Extract key=value pairs | (?<key>[^=&]+)=(?<value>[^&]*) | Names groups; leaves decoding to URLSearchParams |
| Find repeated whitespace | \s+ | Use with replacement " " |
| Split on commas with optional space | \s*,\s* | Normalizes human input |
| A hex colour | `^#(?:[0-9a-fA-F]3 | [0-9a-fA-F]6)$` |
// RegExp.escape landed in modern engines (Node 24+, 2025); fall back where you can't rely on it.
const escape = (s: string) =>
typeof RegExp.escape === "function" ? RegExp.escape(s) : s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const literal = escape(userSearch);
const found = new RegExp(literal, "iu").test(documentText);
Skip that step and a search for a.b matches axb, or worse, a search for ( throws SyntaxError: Invalid regular expression. The constructor also needs double escaping for source-code strings: new RegExp("\\d+") is the same pattern as /\d+/. Both are the kind of tiny difference that produces a very convincing zero-match bug.
Greedy vs lazy: the boundary you didn't mean
Quantifiers are greedy by default — they grab as much as they can, then hand characters back only if the rest of the pattern fails. Add ? and they turn lazy, taking the minimum first. On <b>one</b><b>two</b>, this is the difference between grabbing one tag and grabbing the whole line:
| Pattern | Behavior | Matches on <b>one</b><b>two</b> |
|---|---|---|
<.*> | Greedy — extends to the last > | <b>one</b><b>two</b> (all of it) |
<.*?> | Lazy — stops at the first > | <b> |
<[^>]*> | Negated class — can't cross > at all | <b> |
Reach for the negated character class over the lazy quantifier when you can: [^>]* states the boundary in the pattern itself and doesn't rely on backtracking to find it, so it's both clearer and faster. (And none of these should be how you parse HTML — that's what a DOM parser is for.)
Validation is not parsing
An email-address regex is not an email system. A URL regex is not a URL parser. When the platform has a parser, use it:
const url = new URL(input); // syntax and normalization
const emailLooksPlausible = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input);
The second line may be fine for a form hint; it must not be the thing that decides whether an address exists. Send a verification email instead.
If the pattern contains nested, overlapping repetitions such as
(a+)+, do not run it on untrusted long input. Backtracking engines can spend an absurd amount of time proving a near-match fails.
Three habits that avoid regret
- Name the input contract in a test table. Include valid boundary cases and maliciously long near-misses.
- Prefer a small allowed alphabet.
[^\s]+is convenient;[a-z0-9-]+documents the actual contract. - Make capture groups intentional. Use
(?:...)to group without changing match indexes.
For a pattern you need to inspect against real examples, the regex tester makes flags, captures, and replacements visible. A regex is good when a teammate can edit it six months later, not when it wins a punctuation contest.
Cover photo by Markus Spiske on Pexels.
