Regex Grapheme Cluster In Character Class

ID

javascript.regex_grapheme_cluster_in_class

Severity

high

Remediation Complexity

trivial

Remediation Risk

low

Remediation Effort

low

Resource

Reliability

Language

JavaScript

Tags

code-style

Description

Reports a regex character class that combines a base character with a following Unicode combining mark — visually one grapheme, encoded as two separate class members.

Rationale

Some visible characters are not a single Unicode code point. ö, for instance, is the letter o followed by the combining diaeresis U+0308; it looks like one character on screen but is two in the source. Text pasted from a document, or normalised to NFD, arrives in exactly that form.

A character class is an enumeration of its members, never a sequence, so writing that pair inside […​] does not name the combined grapheme — it names two independent alternatives, the base letter or the bare mark:

// "ö" and "ñ" are written decomposed: o + U+0308, n + U+0303
"nöñn".replace(/[öñ]/g, "X");
// intent: replace the accented "ö" and "ñ"
// actual: [öñ] enumerates o, the diaeresis, n and the tilde, so every plain "o",
//         every plain "n" and every bare combining mark is replaced too

Remediation

Use an alternation instead of a class — ö|ñ matches the two-code-point grapheme as a unit, the way the class was meant to. Normalising the input first makes the intent explicit and lets the precomposed characters be written directly:

"nöñn".replace(/ö|ñ/g, "X");
// or, composing the input first so the class holds precomposed "ö" and "ñ" (one code point each)
"nöñn".normalize("NFC").replace(/[öñ]/g, "X");

What is not reported

A base+mark pair outside a character class — for example already written as an alternation — is the documented fix and is never reported. A run of marks with no base character in front of them, or a class holding a single character on its own, is not a cluster.

A mark spelled out as an explicit four-hex-digit Unicode escape inside a new RegExp("…​") string argument is treated as a deliberate, individually-enumerated code point rather than an accidental grapheme, and is not reported. Inside a regex literal that distinction is not available: the escape is resolved before the pattern is analysed, so an escaped mark and a raw one are reported alike. The pattern behaves identically either way.

© 2026 Xygeni. All rights reserved