Capturing groups
Wrap any slice of a pattern in parentheses pattern:(...) and you get a capturing group. Those parentheses are two tools in one.
- They pull a piece of the match out on its own, so you can read it separately from the whole match.
- They bind a quantifier to everything inside, so the quantifier repeats the group rather than the last character.
That second point is the one beginners trip over, so keep it in mind as we go: a quantifier grabs whatever sits directly to its left, and parentheses change what “the thing to its left” is.
Examples
The fastest way to feel how parentheses behave is to watch a quantifier land on one versus a single character.
Example: yoyoyo
Without parentheses, pattern:yo+ reads as “one subject:y, then subject:o repeated one or more times”. The pattern:+ only sees the subject:o next to it. So it matches match:yoooo or match:yooooooooo, but never a repeated yo.
Put yo in parentheses and the pattern:+ now repeats the whole pair. pattern:(yo)+ matches match:yo, match:yoyo, match:yoyoyo, and so on.
alert( 'Yoyoyo team!'.match(/(yo)+/ig) ); // "Yoyoyo"
y then o repeatedyo as a unit, repeatedExample: domain
Now something with more moving parts: a pattern that finds a website domain.
shop.io
store.shop.io
eu.store.shop.io
Look at the shape. A domain is a run of words, each one followed by a dot, except the last word has no trailing dot. That “word-then-dot, repeated” is exactly a job for a group with a quantifier.
The pattern is pattern:(\w+\.)+\w+:
let regexp = /(\w+\.)+\w+/g;
alert( "blog.dev my.blog.dev".match(regexp) ); // blog.dev,my.blog.dev
The group pattern:(\w+\.) matches one word plus its dot, pattern:+ repeats that for every labeled segment, and the trailing pattern:\w+ catches the final piece that has no dot after it.
It works, but there’s a gap: it can’t match a hyphenated domain like my-blog.dev, because a hyphen isn’t part of the pattern:\w class (which is letters, digits, and underscore only). Widen the character class for every segment except the last: pattern:([\w-]+\.)+\w+.
Example: email
Extend that domain pattern one step and you have a passable email matcher.
An email is name@domain. The name part can hold letters, digits, dots, and hyphens, which is pattern:[-.\w]+. Glue the name, an @, and the domain pattern together:
let regexp = /[-.\w]+@([\w-]+\.)+[\w-]+/g;
alert("[email protected] @ [email protected]".match(regexp)); // [email protected], [email protected]
This is good enough to catch typos and pull addresses out of text. It is not a full RFC-compliant validator, and no regexp really is. The only test that proves an address is real is sending mail to it and seeing if it arrives.
Try that email matcher on your own text. Edit the pattern or the input and re-run to see which addresses the group-based domain rule catches.
Parentheses contents in the match
Groups are numbered left to right by their opening parenthesis. As the engine matches, it remembers the text each group captured and hands it back to you.
str.match(regexp) without the pattern:g flag returns the first match as an array laid out like this:
- Index
0: the entire match. - Index
1: what the first group captured. - Index
2: what the second group captured. - …and so on.
Say you want HTML tags pattern:<.*?> and you’d like the tag’s inner text on its own. Wrap that inner part in parentheses: pattern:<(.*?)>. Now the result holds both the full tag and its contents:
let str = '<p>Welcome aboard!</p>';
let tag = str.match(/<(.*?)>/);
alert( tag[0] ); // <p>
alert( tag[1] ); // p
Nested groups
Parentheses can sit inside other parentheses. The numbering rule stays the same: count opening parentheses from left to right.
Take the tag subject:<button type="submit">. You might want three things out of it:
- Everything inside the angle brackets:
match:button type="submit". - Just the tag name:
match:button. - Just the attributes:
match:type="submit".
Add a group around each: pattern:<(([a-z]+)\s*([^>]*))>. The outer group wraps the whole content, and two inner groups split it into name and attributes.
Number them by the position of each opening paren, scanning left to right:
2 [a-z]+
\s*
3 [^>]*
>In action:
let str = '<button type="submit">';
let regexp = /<(([a-z]+)\s*([^>]*))>/;
let result = str.match(regexp);
alert(result[0]); // <button type="submit">
alert(result[1]); // button type="submit"
alert(result[2]); // button
alert(result[3]); // type="submit"
Index 0 is always the full match. After that come the groups in opening-paren order:
result[1]is the outer grouppattern:(([a-z]+)\s*([^>]*))— the whole tag content.result[2]is the next opening parenpattern:([a-z]+)— the tag name.result[3]ispattern:([^>]*)— the attributes.
Here’s how each group maps onto the string:
Type a different tag below and watch the three numbered slots refill. The outer group holds the whole content, group 2 grabs the name, group 3 the attributes.
Optional groups
A group that’s marked optional with pattern:(...)? still keeps its slot in the result array even when it matched nothing. Its value is just undefined. The array length never depends on which optional groups happened to fire — it’s fixed by how many groups the pattern declares.
Take pattern:b(o)?(x)?: a "b", then maybe an "o", then maybe an "x". Run it against the bare string subject:b:
let match = 'b'.match(/b(o)?(x)?/);
alert( match.length ); // 3
alert( match[0] ); // b (whole match)
alert( match[1] ); // undefined
alert( match[2] ); // undefined
Length is 3 — index 0 plus two groups — and both groups came up empty.
Now the string subject:bx:
let match = 'bx'.match(/b(o)?(x)?/)
alert( match.length ); // 3
alert( match[0] ); // bx (whole match)
alert( match[1] ); // undefined, because there's nothing for (o)?
alert( match[2] ); // x
Same length, 3. The pattern:(o)? group found nothing so its slot is undefined, giving ["bx", undefined, "x"].
Searching for all matches with groups: matchAll
There’s a catch with match and the pattern:g flag: when you ask for all matches, it drops the group details entirely.
let str = '<p> <a>';
let tags = str.match(/<(.*?)>/g);
alert( tags ); // <p>,<a>
You get the list of full matches, but nothing about what each one’s groups captured. In real code you almost always want those group contents.
The method built for this is str.matchAll(regexp). It arrived long after match as the more capable successor. It also looks for matches, but it differs in three ways:
- It returns an iterable object, not a plain array.
- With the
pattern:gflag, every match comes back as a full array including its groups. - When nothing matches, it returns an empty iterable rather than
null.
Watch the first difference bite:
let results = '<p> <a>'.matchAll(/<(.*?)>/gi);
// results is not an array, but an iterable object
alert(results); // [object RegExp String Iterator]
alert(results[0]); // undefined (*)
results = Array.from(results); // turn it into a real array
alert(results[0]); // <p>,p (1st tag)
alert(results[1]); // <a>,a (2nd tag)
Line (*) shows why the iterable trips people up: results[0] is undefined because you can’t index an iterator by number. Array.from materializes it into a proper array first. There’s more on iterables and array-likes in Iterables.
You often don’t need Array.from at all. A for..of loop consumes the iterable directly:
let results = '<p> <a>'.matchAll(/<(.*?)>/gi);
for(let result of results) {
alert(result);
// first alert: <p>,p
// second: <a>,a
}
Destructuring works too, since it also iterates:
let [tag1, tag2] = '<p> <a>'.matchAll(/<(.*?)>/gi);
Each match from matchAll has the same shape as a groups-included match result: an array with extra index (where it starts in the string) and input (the original string) properties.
let results = '<p> <a>'.matchAll(/<(.*?)>/gi);
let [tag1, tag2] = results;
alert( tag1[0] ); // <p>
alert( tag1[1] ); // p
alert( tag1.index ); // 0
alert( tag1.input ); // <p> <a>
Named groups
Counting parentheses to remember which group is which gets old fast, and it breaks the moment you edit the pattern and shift the numbers. Names fix that. Put pattern:?<name> right after a group’s opening paren and you can read it by name later.
Here’s a date matcher in “year-month-day” form:
let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/;
let str = "2024-08-15";
let groups = str.match(dateRegexp).groups;
alert(groups.year); // 2024
alert(groups.month); // 08
alert(groups.day); // 15
Named captures show up on the match’s .groups property, keyed by name.
To find every date, add the pattern:g flag. And since you want the group contents for each hit, reach for matchAll:
let dateRegexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g;
let str = "2024-08-15 2025-02-09";
let results = str.matchAll(dateRegexp);
for(let result of results) {
let {year, month, day} = result.groups;
alert(`${day}.${month}.${year}`);
// first alert: 15.08.2024
// second: 09.02.2025
}
Destructuring result.groups by name reads far cleaner than juggling result[1], result[2], result[3], and it won’t silently break if you insert another group earlier in the pattern.
Enter a few “year-month-day” dates below. Each one that matches is read by name off result.groups and reprinted day-first.
Capturing groups in replacement
str.replace(regexp, replacement) can drop captured text into the replacement string. Reference a group by number with pattern:$n, where pattern:n is the group number.
Swap a name around:
let str = "Maya Ford";
let regexp = /(\w+) (\w+)/;
alert( str.replace(regexp, '$2, $1') ); // Ford, Maya
pattern:$1 is the first captured word, pattern:$2 the second, and the literal ", " between them stays as written.
Change the name below or the replacement template and re-run. Swap the $1 and $2 order, or drop in different literal text between them.
Named groups get referenced with pattern:$<name>. Reformat dates from “year-month-day” to “day.month.year”:
let regexp = /(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})/g;
let str = "2024-08-15, 2025-02-09";
alert( str.replace(regexp, '$<day>.$<month>.$<year>') );
// 15.08.2024, 09.02.2025
Non-capturing groups with ?:
Sometimes you only want the grouping — to attach a quantifier or set up alternation — and you don’t want that text cluttering your results array. Mark a group as non-capturing by starting it with pattern:?:.
So pattern:(?:yo)+ still repeats yo as a unit, but yo never appears as its own array item. Here only the name match:Maya lands in a numbered slot:
let str = "Yoyoyo Maya!";
// ?: excludes 'yo' from capturing
let regexp = /(?:yo)+ (\w+)/i;
let result = str.match(regexp);
alert( result[0] ); // Yoyoyo Maya (full match)
alert( result[1] ); // Maya
alert( result.length ); // 2 (no more items in the array)
Summary
Parentheses do two jobs at once. They bundle part of a pattern so a quantifier applies to the whole bundle, and they capture the matched text for later use.
- Groups are numbered left to right by their opening parenthesis, and nested groups follow the same left-to-right count.
- You can name a group with
pattern:(?<name>...)and read it offmatch.groups.
Getting captured text out depends on the method:
str.matchreturns capturing groups only without thepattern:gflag. Withpattern:gyou get full matches and no group detail.str.matchAllalways returns capturing groups, one full array per match, as a lazy iterable.
Unnamed groups are available by number in the match array; named groups also live on the groups property. Optional groups keep their slot as undefined when they match nothing, so the array length is constant.
In replacements, pull group content into the result with pattern:$n by number or pattern:$<name> by name.
Prefix a group with pattern:?: to make it non-capturing: you keep the grouping for quantifiers and alternation, but it takes no slot in the results and can’t be referenced from a replacement string.