Skip to main content
JavaScript · lesson 9 of 32
JavaScript beginner Lesson 9 of 32

Regular Expressions in JavaScript

Go beyond basic regex matching: why a shared /g regex's test() method alternates true and false on the same string, named capture groups with matchAll, replace() with a callback, and RegExp.escape for safely building patterns from user input.

By SkillByExample · Published

javascript regex regexp

The basics — test, match, named groups, a simple matchAll loop — are covered in Working with Strings. This lesson picks up four things that trip people up once they start reusing regexes in real code: a global regex’s hidden state, named groups across every match, computing a replacement instead of writing one, and safely building a pattern from a string you didn’t write yourself. Every example runs with node on Node v24.18.1.

The g Flag Gives a Regex Memory

A regex literal created with the g flag isn’t just “match everywhere” — the regex object itself keeps a lastIndex property, and test() resumes searching from there on the next call instead of starting over.

const hasBannedWord = /badword/g;

function check(text) {
  return hasBannedWord.test(text);
}

console.log(check("this comment has badword in it"));
console.log(check("this one also has badword"));
console.log(check("and so does this badword too"));
console.log(check("one more with badword"));
$ node lastindex.js
true
false
true
false

Four strings, each containing "badword" exactly once — and the filter alternates true, false, true, false. The first call finds the match and leaves hasBannedWord.lastIndex pointing just past it; the second call starts searching from that same offset in a different string, finds nothing before the end, and (per the spec, on a failed match) resets lastIndex back to 0 — so the third call starts fresh and matches again. The bug is reusing one module-level regex object across calls, not the pattern itself.

Fixing It: Fresh Regex, Reset lastIndex, or Drop g

Three independent fixes, all verified against the same four strings.

function checkFresh(text) {
  return /badword/g.test(text); // a new regex object every call — no shared state
}

const hasBannedWord = /badword/g;
function checkReset(text) {
  hasBannedWord.lastIndex = 0; // reuse the object, but rewind it first
  return hasBannedWord.test(text);
}

console.log(checkFresh("this comment has badword in it"));
console.log(checkFresh("this one also has badword"));
console.log(checkFresh("and so does this badword too"));

console.log(checkReset("this comment has badword in it"));
console.log(checkReset("this one also has badword"));
console.log(checkReset("and so does this badword too"));
$ node fix.js
true
true
true
true
true
true

Both come back true three times in a row, as expected for three strings that each really do contain "badword". checkFresh sidesteps the whole problem by never reusing a regex object; checkReset keeps the one object but rewinds it before every search. A third option isn’t shown here because it needs no code change at all: test() and exec() only consult lastIndex when the regex has g or y — drop the flag if you never need anything beyond a single match.

Named Capture Groups with matchAll

(?<name>...) groups make a match’s pieces self-describing, and matchAll hands you one match object per occurrence — each with its own .groups.

const log = `2026-01-15 09:02:11 ERROR auth failed
2026-01-15 09:02:45 INFO login ok
2026-01-15 09:03:02 ERROR db timeout`;

const lineRe = /(?<date>\d{4}-\d{2}-\d{2}) (?<time>\d{2}:\d{2}:\d{2}) (?<level>\w+) (?<msg>.+)/g;

for (const match of log.matchAll(lineRe)) {
  console.log(match.groups.level.padEnd(5), match.groups.time, "->", match.groups.msg);
}

const errorsOnly = [...log.matchAll(lineRe)].filter((m) => m.groups.level === "ERROR");
console.log(errorsOnly.length, "errors");
$ node lognamed.js
ERROR 09:02:11 -> auth failed
INFO  09:02:45 -> login ok
ERROR 09:03:02 -> db timeout
2 errors

match.groups.level and match.groups.time read like object properties because, once matched, they are — no counting $1, $2, $3 and losing track of which number means what. matchAll always returns an iterator, even with no matches, so [...log.matchAll(lineRe)] is safe to .filter() directly; match() with the same global regex would have given back only the matched strings, with no .groups at all.

replace() with a Callback: Computing Each Replacement

A replacement string can only insert what a match already contains ($1, $&, and so on). A replacement function can compute something new for every match.

const prices = "Widget $5, Gadget $12, Gizmo $3";

const withTax = prices.replace(/\$(\d+)/g, (match, amount, offset, full) => {
  const taxed = (Number(amount) * 1.08).toFixed(2);
  return `$${taxed}`;
});
console.log(withTax);

prices.replace(/\$(\d+)/g, (match, amount, offset) => {
  console.log({ match, amount, offset });
  return match;
});
$ node replacecb.js
Widget $5.40, Gadget $12.96, Gizmo $3.24
{ match: '$5', amount: '5', offset: 7 }
{ match: '$12', amount: '12', offset: 18 }
{ match: '$3', amount: '3', offset: 29 }

Each $N substring becomes its own callback call — match is the whole "$5", amount is capture group 1 ("5"), and whatever the callback returns replaces that match in the output. The second call ignores the result and just logs the arguments: offset is where the match starts in the original string, which is why it climbs from 7 to 18 to 29 as the function walks further into prices. A template string with $1 could only copy amount back out unchanged — computing the taxed price needs real code.

RegExp.escape: Safely Building a Pattern from a String

Passing an arbitrary string straight to new RegExp() lets any regex metacharacter it contains change what the pattern means — often without throwing, which is worse than an error.

const raw = "1.5*x";

const literal = new RegExp(raw);
console.log(literal);
console.log(literal.test("1.5*x"));   // expected match: false
console.log(literal.test("125555x")); // an unrelated string: true

const safe = new RegExp(RegExp.escape(raw));
console.log(safe);
console.log(safe.test("1.5*x"));
console.log(safe.test("125555x"));
$ node escape.js
/1.5*x/
false
true
/\x31\.5\*x/
true
false

new RegExp("1.5*x") doesn’t mean “the literal text 1.5*x” — . means “any character” and 5* means “zero or more 5s”, so the pattern actually matches 1, then anything, then any number of 5s, then x. That’s why it reports false against the exact string it was built from, and true against "125555x", which doesn’t even contain a . or a *. RegExp.escape puts a backslash in front of every character that new RegExp would otherwise treat specially — except the leading 1, which comes back as \x31, a hex escape, instead of \1. A leading digit (or letter) gets hex-escaped rather than backslash-escaped specifically so it can’t be misread as a backreference like \1 or merge with whatever escape sequence precedes it. Either way, safe matches the string literally, exactly where it was built from raw — this is the fix for any pattern assembled from a variable rather than typed as a literal.

Key Takeaways

  • A g-flagged regex object carries state in lastIndex; test()/exec() resume from there, which can alternate true/false on an unchanged string. matchAll() reads that same lastIndex as its starting point, and replace() resets it on a global regex (or reads and advances it on a sticky one) — so reusing one regex object across a test() call and a later replace()/matchAll() call still carries risk.
  • matchAll + named groups gives every match a self-describing .groups object instead of numbered captures.
  • A replace() callback receives the match, then each capture group, then the offset, then the whole string — and whatever it returns is computed, not just copied from the match.
  • RegExp.escape(str) makes str safe to drop into new RegExp() — without it, metacharacters in the string silently change what the pattern matches, usually without an error.

Practice

  1. Reproduce and fix the lastIndex trap without g at all. Rewrite check from the first example to drop the g flag entirely, and confirm all four calls now return true.
Solution
function check(text) {
  return /badword/.test(text); // no g flag, no lastIndex to carry between calls
}

console.log(check("this comment has badword in it"));
console.log(check("this one also has badword"));
console.log(check("and so does this badword too"));
console.log(check("one more with badword"));
$ node p1.js
true
true
true
true

Without g, test() always starts from index 0 — there’s no lastIndex state to carry between calls, so all four strings (each containing the word once) correctly return true.

  1. Show that matchAll reads a leftover lastIndex. Create one /a/g regex, call .test("aaaa") once so lastIndex advances past 0, then call "aaaa".matchAll(regex) on that same regex object and count the matches. Reset lastIndex to 0 and call matchAll again, and confirm the count changes.
Solution
const text = "aaaa";
const re = /a/g;

console.log(re.test(text)); // advances lastIndex past the first match
console.log("lastIndex after test:", re.lastIndex);
console.log([...text.matchAll(re)].length, "matches before reset");

re.lastIndex = 0;
console.log([...text.matchAll(re)].length, "matches after reset");
$ node p2.js
true
lastIndex after test: 1
3 matches before reset
4 matches after reset

test() leaves lastIndex at 1, and matchAll starts searching from there — it finds the 3 remaining "a"s, not all 4. matchAll never changes the regex object’s own lastIndex (it clones the regex internally), so re.lastIndex is still 1 right up until the explicit reset; only after setting it back to 0 does matchAll see the whole string again.

  1. Convert temperatures with a replace() callback. Given "Temp readings: 32F, 98F, 212F", replace every NNF with its Celsius equivalent rounded to one decimal, formatted like "0.0C".
Solution
const sentence = "Temp readings: 32F, 98F, 212F";

const converted = sentence.replace(/(\d+)F/g, (match, f) => {
  const celsius = ((Number(f) - 32) * 5 / 9).toFixed(1);
  return `${celsius}C`;
});
console.log(converted);
$ node p3.js
Temp readings: 0.0C, 36.7C, 100.0C

The callback runs once per NNF match, so each temperature is converted using its own captured number (f) rather than a single value shared by the whole string.

  1. Build a safe search highlighter with RegExp.escape. Write highlight(text, needle) that wraps every occurrence of needle in [...], even when needle itself contains regex metacharacters like $ or +.
Solution
function highlight(text, needle) {
  const re = new RegExp(RegExp.escape(needle), "gi");
  return text.replace(re, (match) => `[${match}]`);
}

console.log(highlight("Price: $5.00 (was $10.00)", "$5.00"));
console.log(highlight("Find C++ jobs near C++ meetups", "C++"));
$ node p4.js
Price: [$5.00] (was $10.00)
Find [C++] jobs near [C++] meetups

Without RegExp.escape, $5.00 and C++ would each be interpreted as a pattern ($ as an anchor, + as “one or more of the previous character”) instead of literal text to find — escaping needle first is what lets an arbitrary search term be dropped safely into new RegExp().

Frequently Asked Questions

Why does calling test() twice on the same regex give different results for the same string?
Only when the regex has the g (or y) flag. A global regex object keeps a lastIndex property that remembers where its last successful match ended, and the next test() or exec() call resumes searching from there instead of from the start. If the string has one match, the first call finds it and advances lastIndex past it; the second call starts searching after that point, finds nothing, and resets lastIndex to 0 for the call after that — producing true, false, true, false on an unchanged string.
Do replace() and matchAll() have the same lastIndex problem as test()?
Not the same problem, but they are not fully isolated from it either. matchAll reads the regex's lastIndex as its starting point before it begins matching, so a prior test() call that left lastIndex nonzero makes a later matchAll() on that same regex silently skip earlier matches. replace() with a global (g) regex resets lastIndex to 0 before it runs, which is itself a mutation; with a sticky (y) regex, replace() both reads and advances lastIndex as it goes. Calling replace() or matchAll() repeatedly on a fresh regex literal, or one that test()/exec() has never touched, is safe — the trap only resurfaces when the same regex object is reused across a test() call and a later replace()/matchAll() call.
How do I avoid the lastIndex trap with test()?
Three options: create a fresh regex literal for each call instead of reusing one stored in a variable, reset lastIndex to 0 before each call if you must reuse the same regex object, or drop the g flag entirely if you only ever need a single true/false answer and don't need to find every match.
What does a regex's replace() callback function receive?
The full matched text first, then one argument per capture group in order, then the match's offset (its starting index in the string), then the whole original string. Whatever the callback returns is substituted for that match, computed fresh each time rather than written as a fixed $1-style template.
What does RegExp.escape do?
It takes a plain string and returns a version where every character with special meaning in a regex pattern is escaped, so the result matches that string literally when passed to new RegExp(). It exists because characters like . and * change the meaning of a pattern built from untrusted or user-supplied text, often without throwing an error — just matching the wrong thing silently.