AI-assisted disclosure: This article was prepared with AI assistance. Every JavaScript example was executed with the companion Node.js validation script; no human editorial review was used.
A regular expression can be correct in isolation and still fail in an application. The failure may come from a flag, a Unicode representation, the API used to consume the expression, or the mutable state of a shared RegExp object.
That is why “try another pattern” is often a poor debugging strategy. A better approach is to turn the bug into a small test matrix.
The matrix has four useful axes:
| Axis | Questions to test |
|---|---|
| Input | Is the exact failing string present, including whitespace, normalization, and invisible code points? |
| Pattern and flags | What changes with u, g, i, m, s, or y? |
| Consumer API | Are we calling test, exec, matchAll, or replace? |
| Expected result and state | Which matches, groups, replacement text, and lastIndex values should result? |
This turns an anecdote such as “the regex works in one place but not another” into a reproducible statement such as:
With this exact input,
/^.$/fails,/^.$/usucceeds, and the input contains one Unicode code point represented by two UTF-16 code units.
Start with an executable matrix
The smallest useful harness is just data plus assertions:
import assert from "node:assert/strict";
const cases = [
{
name: "an emoji needs Unicode code-point mode",
run: () => /^.$/u.test("💡"),
expected: true,
},
{
name: "a decomposed character contains two code points",
run: () => /^.$/u.test("e\u0301"),
expected: false,
},
{
name: "NFC normalization composes that character",
run: () => /^.$/u.test("e\u0301".normalize("NFC")),
expected: true,
},
];
for (const testCase of cases) {
assert.deepEqual(testCase.run(), testCase.expected, testCase.name);
}
Keep the original failing input in the matrix. Add reduced examples later, but do not replace the evidence with a tidier string that may no longer reproduce the bug.
When the input is suspicious, inspect both code units and code points:
const input = "💡";
console.log(input.length); // 2 UTF-16 code units
console.log(Array.from(input).length); // 1 Unicode code point
Those two lengths explain why a dot without the u flag does not mean “one visible character.”
Unicode is more than adding the u flag
The u flag changes pattern parsing and makes constructs such as . operate on Unicode code points rather than individual UTF-16 surrogate halves:
console.log(/^.$/.test("💡")); // false
console.log(/^.$/u.test("💡")); // true
It also enables Unicode property escapes, which are often clearer than long hand-written ranges:
const lettersOnly = /^\p{Letter}+$/u;
console.log(lettersOnly.test("東京Cafe")); // true
console.log(lettersOnly.test("東京2")); // false
But u does not normalize text. The precomposed string "é" and the decomposed string "e\u0301" look alike while containing different code-point sequences. Decide whether normalization is part of the application contract, then test that decision explicitly:
const oneCodePoint = /^.$/u;
console.log(oneCodePoint.test("é")); // true
console.log(oneCodePoint.test("e\u0301")); // false
console.log(oneCodePoint.test("e\u0301".normalize("NFC"))); // true
Do not silently normalize identifiers, passwords, signatures, or other protocol data unless their specification permits it. Normalization is a data decision, not a universal regex repair.
Test capture shape, not only the full match
A successful full match does not prove that downstream capture handling is correct. Optional captures can be undefined, and a capture inside a repeated group stores only the final captured value.
const optional = /(?<word>[A-Za-z]+)(?:-(?<suffix>\d+))?/;
const match = optional.exec("alpha");
console.log(match.groups.word); // "alpha"
console.log(match.groups.suffix); // undefined
const repeated = /(ab)+/.exec("abab");
console.log(repeated[0]); // "abab"
console.log(repeated[1]); // "ab", not an array of both repetitions
For every meaningful capture, add an assertion. If application code expects a value to be present, test the absent branch too.
Named groups make a matrix easier to read because the assertion describes the domain rather than a position:
assert.equal(match.groups.word, "alpha");
assert.equal(match.groups.suffix, undefined);
Replacement is a separate behavior
Matching and replacement should be separate rows in the matrix. A pattern may find the right spans while the replacement string refers to the wrong group or handles an optional group incorrectly.
Named captures give replacement templates a useful amount of self-documentation:
const person = /(?<first>\p{Letter}+)\s+(?<last>\p{Letter}+)/gu;
const input = "Ada Lovelace; Grace Hopper";
const output = input.replace(person, "$<last>, $<first>");
console.log(output);
// Lovelace, Ada; Hopper, Grace
When the replacement needs conditions, use a function and assert the final string rather than testing only the regex:
const version = /v(?<major>\d+)(?:\.(?<minor>\d+))?/g;
const output = "v2 v3.7".replace(version, (...args) => {
const groups = args.at(-1);
return groups.minor === undefined
? `${groups.major}.0`
: `${groups.major}.${groups.minor}`;
});
console.log(output); // 2.0 3.7
The callback argument list is awkward, so isolate that behavior behind a named function if it appears in production code.
Treat global regexes as mutable objects
The g and y flags make a RegExp stateful. Calls to test and exec read and write lastIndex.
const shared = /\d+/g;
const input = "a1 b2";
console.log(shared.test(input), shared.lastIndex); // true, 2
console.log(shared.test(input), shared.lastIndex); // true, 5
console.log(shared.test(input), shared.lastIndex); // false, 0
This often causes alternating test results when a module-level regex is reused across requests or validation calls:
const hasNumber = /\d/g;
hasNumber.test("item 7"); // true
hasNumber.test("item 7"); // false: the same object starts from its old lastIndex
Three reliable fixes are:
- Remove
gwhen the operation is a yes/no test. - Construct a fresh
RegExpfor each independent scan. - Reset
lastIndexdeliberately and assert the state transition.
For collecting every match, matchAll is usually easier to reason about. It iterates using a cloned regex state and leaves the original object's lastIndex unchanged:
const digits = /\d+/g;
const values = [..."a1 b22".matchAll(digits)].map((match) => match[0]);
console.log(values); // ["1", "22"]
console.log(digits.lastIndex); // 0
There is one more state boundary worth testing: zero-length matches. A manual exec loop can stall if the expression succeeds without consuming input. Either advance lastIndex yourself after an empty match or use an API whose iteration behavior is already defined for that case:
const positions = [..."ab".matchAll(/(?=\w)/g)].map((match) => match.index);
console.log(positions); // [0, 1]
Compare APIs deliberately
JavaScript exposes several regex consumers because they answer different questions:
| API | Useful result | State concern |
|---|---|---|
re.test(input) |
Boolean existence check | Mutates lastIndex with g or y
|
re.exec(input) |
One match with captures and index | Mutates lastIndex with g or y
|
input.matchAll(re) |
Iterable of all detailed matches | Requires g; does not mutate the original regex |
input.replace(re, value) |
Transformed string | Replacement semantics need their own assertions |
When two environments disagree, first confirm that they are using the same input, flags, and API. Comparing test in one place with matchAll in another is not a controlled experiment.
For a browser-local scratchpad while building the matrix, the RegexRegex JavaScript guide and live checker exposes matches, capture groups, replacement previews, and errors together. It is optional: every example in this article can be reproduced with Node.js alone.
A practical debugging sequence
Here is the workflow I use for a regex bug:
- Capture the exact input. Preserve escapes, line endings, normalization, and surrounding characters.
-
Record the exact construction. A literal and
new RegExp(string)have different escaping layers. -
Freeze the consumer API. Note whether production calls
test,exec,matchAll, orreplace. - Build the smallest relevant flag matrix. Change one flag at a time and state why that row exists.
- Assert full matches and captures. Include optional and repeated-group branches.
- Assert replacement output separately. The right match can still produce the wrong text.
-
Assert
lastIndexforgory. Run the same object more than once. - Add Unicode adversaries. Include an astral character, combining mark, and non-ASCII letters when the domain permits them.
- Minimize only after reproduction. Keep the original case as a permanent regression test.
- Run in supported runtimes. Engine features and Unicode data can differ across deployed browser and Node versions.
The goal is not to test every theoretical string. It is to make each assumption visible. Once input representation, flags, API semantics, captures, replacement output, and mutable state are separate rows, most “mysterious” regex failures become ordinary failing assertions.
Keep the matrix executable
Put the cases your application depends on in a small regex-matrix.mjs file using node:assert/strict, and make the process exit non-zero on the first failed expectation. Run it with:
node regex-matrix.mjs
Store the expected results with the code so a later regex edit cannot quietly change behavior without changing an executable expectation.
Top comments (0)