Named capture groups with static type inference
Learn how to use .capture() in TS-Rex to extract named groups from regex matches with fully inferred TypeScript types — no casting required.
Named capture groups let you label the parts of a regex match and retrieve them by name instead of by numeric index. In TS-Rex, every .capture() call you chain onto a builder is recorded at the type level, so by the time you call .compile() and then .exec(), TypeScript already knows the exact shape of your result — no type assertions, no as string, no guessing.
A simple two-group pattern
The most direct way to see this in action is with a name parser. You build up the pattern by chaining methods, give each group a string identifier, and the result type is inferred automatically.
import { rx } from '@fajarnugraha37/ts-rex';
const pattern = rx()
.startOfInput()
.capture('firstName', rx().oneOrMore(rx().wordChar()))
.whitespace()
.capture('lastName', rx().oneOrMore(rx().wordChar()))
.endOfInput()
.compile();
const result = pattern.exec('John Doe');
if (result.isMatch) {
// TypeScript infers these as `string` — no casting needed
console.log(result.firstName); // "John"
console.log(result.lastName); // "Doe"
console.log(result.match); // "John Doe"
}
The MatchResult discriminated union
exec() returns a discriminated union on the isMatch boolean. Before you access any captured field you must narrow the type with an isMatch check. TypeScript enforces this — if you try to read result.firstName outside the if block, the compiler will warn you that it might be undefined.
The two branches of the union are:
| Branch | Shape |
|---|---|
| Success | { isMatch: true, match: string, firstName: string, lastName: string } |
| Failure | { isMatch: false, match: null, firstName: undefined, lastName: undefined } |
The failure branch sets every captured field to undefined, so you can safely destructure anywhere — as long as you check isMatch first.
const result = pattern.exec('John Doe');
// Narrowed inside the if block
if (result.isMatch) {
const { firstName, lastName, match } = result;
// firstName: string ✓
// lastName: string ✓
// match: string ✓
}
// Outside the block — both branches are possible
// result.firstName → string | undefined
What TypeScript infers
You can inspect the inferred type of a compiled pattern’s exec return directly. After two .capture() calls named 'firstName' and 'lastName', the success branch looks like this:
type SuccessBranch = {
isMatch: true;
match: string;
firstName: string;
lastName: string;
};
Each new .capture('name', builder) call merges Record<'name', string> into the running type state via TypeScript’s intersection types. There is zero runtime overhead — the type accumulation happens entirely at compile time through phantom generic parameters on RegexBuilder<TCaptures, TFlags>.
Capture names must be valid JavaScript identifiers. TS-Rex validates names at runtime and throws if you pass something like '1invalid' or 'my-group'. Stick to names you would use as a variable: camelCase, snake_case, or PascalCase all work.
Richer types from multiple captures
Patterns with more captures produce correspondingly richer result types. Here is a date parser that extracts four named groups:
import { rx } from '@fajarnugraha37/ts-rex';
const datePattern = rx()
.startOfInput()
.capture('year', rx().times(4, rx().digit()))
.literal('-')
.capture('month', rx().times(2, rx().digit()))
.literal('-')
.capture('day', rx().times(2, rx().digit()))
.endOfInput()
.compile();
const result = datePattern.exec('2026-05-12');
if (result.isMatch) {
// All four fields are inferred as `string`
console.log(result.year); // "2026"
console.log(result.month); // "05"
console.log(result.day); // "12"
}
// Inferred success type:
// {
// isMatch: true;
// match: string;
// year: string;
// month: string;
// day: string;
// }
All captured values are always string, even when the content looks numeric. RegExp capture groups return the matched text as-is. Parse to a number with Number(result.year) after the isMatch check if needed.
Nested captures
You can pass a builder that itself contains .capture() calls as the second argument to an outer .capture(). Both the outer and inner group names are merged into the result type.
import { rx } from '@fajarnugraha37/ts-rex';
const pattern = rx()
.capture(
'outer',
rx()
.literal('hello')
.capture('inner', rx().oneOrMore(rx().wordChar()))
)
.compile();
// Compiled pattern: (?<outer>hello(?<inner>(?:\w)+))
const result = pattern.exec('helloworld');
if (result.isMatch) {
console.log(result.outer); // "helloworld"
console.log(result.inner); // "world"
}
// Inferred success type:
// {
// isMatch: true;
// match: string;
// outer: string;
// inner: string;
// }