TL;DR: RegExp.escape(str) returns a version of str that matches itself literally when you put it in a new RegExp(). It is Baseline since May 2025, ships in Node.js 24, and fixes two bugs the classic copy-pasted helper still has.
The dot that let everyone in
Somebody needs to check that a redirect URL stays on your domain. Template literals make it feel easy:
const domain = "example.com";
const allowed = new RegExp(`^https://${domain}/`);
allowed.test("https://example.com/login"); // true
allowed.test("https://exampleXcom/login"); // true
That second true is the bug. In a regex, . means "any character", so example.com also matches exampleXcom, example-com, and anything else an attacker can register. Your allowlist is now a suggestion.
The usual fix is a helper copied from a Stack Overflow answer that is older than some of your coworkers:
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
Every project has one. Some projects have three, all slightly different.
Meet RegExp.escape()
RegExp.escape(string)
It takes a string and returns a string. Every character that could mean something in a pattern comes back escaped, so the result only ever matches the original text.
const safe = new RegExp(`^https://${RegExp.escape(domain)}/`);
safe.test("https://example.com/login"); // true
safe.test("https://exampleXcom/login"); // false
Here is what it actually produces:
RegExp.escape("example.com"); // \x65xample\.com
RegExp.escape("$9.99 (sale)"); // \$9\.99\x20\(sale\)
RegExp.escape("a-b"); // \x61\x2db
It looks like a cat walked across the keyboard, and that is on purpose. Syntax characters like ., $, and ( get a backslash. Other punctuation like - and spaces become \x hex escapes. And the first character gets hex-escaped if it is a letter or digit. The output looks odd, but each of those rules has a reason.
Why not just keep the old helper
Because the old helper has two holes, and both are easy to hit.
The dash inside a character class. The helper leaves - alone. That is fine in most places, but not between square brackets:
const oldClass = new RegExp(`[${escapeRegExp("a-z")}]`);
const newClass = new RegExp(`[${RegExp.escape("a-z")}]`);
oldClass.test("m"); // true, it became the range a to z
newClass.test("m"); // false
newClass.test("-"); // true
You wanted "a, dash, or z". You got the whole alphabet.
The digit after a backreference. Say you match a repeated letter and then a literal "1":
const oldRe = new RegExp(`(a)\\1${escapeRegExp("1")}`);
const newRe = new RegExp(`(a)\\1${RegExp.escape("1")}`);
oldRe.source; // (a)\11
oldRe.test("aa1"); // false
newRe.source; // (a)\1\x31
newRe.test("aa1"); // true
The old helper glues the 1 onto \1, and the pattern now asks for backreference 11 instead. RegExp.escape() hex-escapes a leading digit or letter for exactly this reason. The two pieces can never merge into one token.
A realistic example: highlight search terms
A search box where people type things like C++ and (the):
function highlight(text, terms) {
const pattern = terms.filter(Boolean).map(RegExp.escape).join("|");
if (!pattern) return text;
return text.replace(new RegExp(pattern, "gi"), (m) => `<mark>${m}</mark>`);
}
highlight(
"Is C++ faster than C#? Ask a.k.a. (the) experts.",
["c++", "c#", "a.k.a.", "(the)"],
);
// Is <mark>C++</mark> faster than <mark>C#</mark>? Ask <mark>a.k.a.</mark> <mark>(the)</mark> experts.
Without escaping, c++ throws "Nothing to repeat", and a.k.a. matches far more than it should. Passing RegExp.escape straight to map() works too, because it ignores the extra index argument. Flags stay yours: new RegExp(RegExp.escape("A.B"), "i").test("a.b") is true.
In real HTML, escape the text for HTML too. RegExp.escape() protects the pattern, not the page.
Every output above comes from running the code in Node.js 24.11.
Can I use it?
Yes, almost everywhere:
| Runtime | Since |
|---|---|
| Chrome | 136 |
| Firefox | 134 |
| Safari | 18.2 |
| Node.js | 24.0.0 |
| Deno | 2.3.2 |
That made it Baseline Newly available in May 2025. TC39 moved it to Stage 4 in February 2025, and it is slated for ECMAScript 2026. For older browsers or Node 22, feature-detect it and fall back to a polyfill such as core-js.
const escape = RegExp.escape ?? ((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
That fallback still has the two holes above, so treat it as a bridge, not a destination.
Gotchas
1. It only takes strings. Numbers do not get coerced:
RegExp.escape(42);
// TypeError: input argument must be a string
Wrap IDs and counts in String() first.
2. It does not touch the replacement string. $&, $1, and friends are special in the second argument of replace(), and RegExp.escape() has nothing to do with that side:
"Total: TOTAL".replace(/TOTAL/, "$&"); // Total: TOTAL
"Total: TOTAL".replace(/TOTAL/, () => "$&"); // Total: $&
If the replacement comes from a user, pass a function instead.
3. It works with the u and v flags. The hex escapes are valid in Unicode mode too. new RegExp(`[${RegExp.escape("a-z")}]`, "v") matches - and not m, the same as without a flag.
4. You may not need a regex at all. For a plain literal search, str.includes(), str.split(), or str.replaceAll(".", "-") with a string argument are simpler. Reach for RegExp.escape() when the literal text sits inside a bigger pattern.
5. Do not snapshot its output by eye. Results like \x65xample\.com are correct but surprising. Test what the regex matches, not what the escaped string looks like.
Wrap-up
RegExp.escape() is the helper you kept copying, minus the bugs you did not know it had. Go grep your codebase for \\$& and retire one today.
Sources
- MDN: RegExp.escape()
- TC39 proposal: RegExp escaping
- TC39 finished proposals
- MDN browser compat data for RegExp
This article was written with the help of AI and checked against the sources above.
Top comments (0)