JavaScript · lesson 9 of 32
Beginner
13- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Regular Expressions in JavaScript
- 10 Arrays in JavaScript
- 11 Objects in JavaScript
- 12 Map, Set, WeakMap, and WeakSet in JavaScript
- 13 JSON in JavaScript: stringify, parse, and What Gets Lost
Intermediate
15- 14 Error Handling in JavaScript
- 15 Dates and Times in JavaScript: Date Pitfalls and Temporal
- 16 Object-Oriented Programming in JavaScript
- 17 Prototypes and Prototype Chain in JavaScript
- 18 Closures in JavaScript
- 19 this, call, apply, and bind in JavaScript
- 20 JavaScript Event Loop Explained
- 21 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 22 Iterators and Generators in JavaScript
- 23 JavaScript Modules: ESM and CommonJS
- 24 DOM Manipulation in JavaScript
- 25 Browser Web APIs in JavaScript
- 26 Files, Streams, and the Command Line in Node.js
- 27 Testing JavaScript with Jest and Vitest
- 32 JavaScript Interview Preparation: Top 35 Questions
Advanced
4Also part of JavaScript
1Regular 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
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 inlastIndex;test()/exec()resume from there, which can alternatetrue/falseon an unchanged string.matchAll()reads that samelastIndexas its starting point, andreplace()resets it on a global regex (or reads and advances it on a sticky one) — so reusing one regex object across atest()call and a laterreplace()/matchAll()call still carries risk. matchAll+ named groups gives every match a self-describing.groupsobject 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)makesstrsafe to drop intonew RegExp()— without it, metacharacters in the string silently change what the pattern matches, usually without an error.
Practice
- Reproduce and fix the
lastIndextrap withoutgat all. Rewritecheckfrom the first example to drop thegflag entirely, and confirm all four calls now returntrue.
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.
- Show that
matchAllreads a leftoverlastIndex. Create one/a/gregex, call.test("aaaa")once solastIndexadvances past 0, then call"aaaa".matchAll(regex)on that same regex object and count the matches. ResetlastIndexto0and callmatchAllagain, 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.
- Convert temperatures with a
replace()callback. Given"Temp readings: 32F, 98F, 212F", replace everyNNFwith 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.
- Build a safe search highlighter with
RegExp.escape. Writehighlight(text, needle)that wraps every occurrence ofneedlein[...], even whenneedleitself 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().