JAVASCRIPT / REGULAR EXPRESSIONS
Named groups and readable extraction
Label captures with (?<name>...) and read values from match.groups, \k<name>, and $<name> instead of counting group positions.
What you will learn
- Read captures by meaning with match.groups.field instead of indexes like m[3]
- Repeat a captured value inside the same pattern using \k<name>
- Rewrite text with $<name> in a replacement string, or the groups argument in a replacer
- Expect undefined from optional named groups and from groups on unnamed patterns
Understanding Named groups and readable extraction
A named group is written (?<name>...) and is an ordinary capturing group that carries a label. When a match succeeds against a pattern containing at least one such group, the result object gains a groups property whose keys are those labels. m[1] and m.groups.date hold the identical string: the name is an additional way in, not a different kind of group.
The reason to bother is that numeric indexes encode position, and position changes for reasons unrelated to your intent. Insert one group near the front of a pattern and every later m[2], m[3], $1 and \2 quietly shifts by one, which is a class of bug that no error message will point at. Names are attached to the group itself, so editing the pattern around them leaves the reading code alone. The groups object is also created fresh per match with a null prototype, which is why a group legitimately named constructor or toString cannot collide with an inherited property.
Names work in four places beyond property access: \k<name> backreferences a captured value later in the pattern, $<name> pulls it into a replacement string, a replacer function receives the whole groups object as its last argument, and with the d flag m.indices.groups gives per-name offsets. In all of them a group that exists in the pattern but did not participate in the match is undefined rather than an empty string, so optional pieces need either a destructuring default or ?? before you use them.
const logLine = /^(?<date>\d{4}-\d{2}-\d{2})T(?<time>\d{2}:\d{2})\s+(?<level>[A-Z]+)\s+(?<msg>.+)$/;
const m = "2026-09-03T20:24 WARN disk usage at 91%".match(logLine);
console.log(m.groups.level);
console.log(m.groups.date, m.groups.time);
const { date, msg } = m.groups;
console.log(date + ": " + msg);
console.log(m[3] === m.groups.level);
console.log(Object.keys(m.groups).join(","));
console.log(Object.getPrototypeOf(m.groups));A named group is still capture group N; the name is a stable alias, so extraction reads by meaning instead of by position.
Worked examples
Reordering fields with $<name>
Named references in a replacement string let the output order differ from the group order in the pattern.
const iso = /(?<y>\d{4})-(?<mo>\d{2})-(?<d>\d{2})/g;
console.log("due 2026-09-03, sent 2026-08-14".replace(iso, "$<d>/$<mo>/$<y>"));
const spelled = "2026-09-03".replace(iso, (...args) => {
const g = args[args.length - 1];
return `${Number(g.d)} of month ${Number(g.mo)}, ${g.y}`;
});
console.log(spelled);Example explained
Line 1$<d>/$<mo>/$<y> is resolved from the match's groups object, so the printed order is independent of which group is 1, 2 or 3.
Line 2The g flag rewrites every match, and replace manages lastIndex itself, so the same regex object stays reusable on the next line.
Line 3A replacer function is handed the groups object as its final argument, but only when the pattern declares named groups; that is why args[args.length - 1] is the groups object here.
Matching a delimiter against itself with \k<name>
A named backreference forces the closing quote to be the same character as the opening quote.
const quoted = /(?<q>["'])(?<text>.*?)\k<q>/g;
const src = `title="Hello" author='Ada' broken="oops'`;
for (const m of src.matchAll(quoted)) {
console.log(m.groups.q, "->", m.groups.text);
}Example explained
Line 1\k<q> matches whatever character group q captured, so a double quote cannot be closed by a single quote.
Line 2.*? is lazy, so text stops at the first delimiter that satisfies the backreference instead of running to the last quote in the string.
Line 3broken="oops' produces no match at all, because no later double quote exists to satisfy \k<q>.
Line 4matchAll yields a separate match object per match, so each groups object belongs to that one match.
Optional groups and missing groups objects
Shows that a non-participating named group is undefined, and that groups itself is undefined when the pattern has no names.
const dur = /^(?<value>\d+(?:\.\d+)?)(?<unit>ms|s|m)?$/;
for (const input of ["250ms", "1.5s", "42"]) {
const { value, unit = "s" } = input.match(dur).groups;
console.log(input, "->", value, unit);
}
console.log("42".match(/^\d+$/).groups);Example explained
Line 1For "42" the optional unit group never participates, so groups.unit is undefined rather than an empty string.
Line 2Because the value is undefined, the destructuring default unit = "s" fires; it would not fire for "".
Line 3(?:\.\d+)? is deliberately non-capturing, so the fractional part never appears in groups and only meaningful parts are named.
Line 4The last pattern declares no named groups, so groups is undefined and reading .unit off it would throw.
Important notes
m.groups has a null prototype, which is what makes names like toString safe as keys; the trade-off is that m.groups.hasOwnProperty("unit") throws, so use Object.hasOwn(m.groups, "unit") or "unit" in m.groups.
Naming a group does not remove its number, and the name must be a valid identifier: (?<zip-code>...) is a SyntaxError, while (?<zipCode>...) works and is still m[1].
Common mistakes
Writing $name or ${name} in the replacement string. Neither is a substitution pattern, so the result silently contains those literal characters instead of the captured value.
Calling m.groups.field on a pattern that has no (?<...>) group. groups is undefined there, so it fails with TypeError: Cannot read properties of undefined.
Reusing a name, as in /(?<n>\d+)-(?<n>\d+)/. The regex literal throws a SyntaxError about a duplicate capture group name before any input is matched; recent engines allow a repeated name only across mutually exclusive alternation branches.
Try it yourself
Change, predict, then run
In a browser console, write one regex with named groups host and port that matches both "cache-1:6379" and "cache-1", then log groups.host and groups.port ?? 6379 for each input so you can watch the optional group come back as undefined and the fallback take over.
Open the JavaScript workspaceCheck your understanding
A pattern uses only unnamed groups, such as /(\d{4})-(\d{2})/, and it is called as str.replace(re, "$<year>-ok"). What ends up in the result?
- An empty string where $<year> was, because that group did not participate in the match
- The text undefined, because groups.year does not exist for this pattern
- The literal text $<year>-ok, because $< is only a named reference when the pattern itself declares named groups
- A SyntaxError, because $< is not allowed in a replacement string
Show answer
The replacement scanner treats $< specially only when the match carries a groups object, and that object exists only if the pattern declares at least one named group; with none, $< and everything after it are copied through as ordinary characters. The empty-string option is tempting because it describes the other case: when the pattern does have named groups but the referenced name is unknown or did not participate, $<name> is replaced with an empty string.