Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/fajarnugraha37/ts-rex/llms.txt

Use this file to discover all available pages before exploring further.

Groups and alternation are the structural backbone of any non-trivial regex. In TS-Rex, these methods do more than emit regex syntax — they directly shape the TypeScript type of the compiled result. Every .capture() call widens TCaptures, every .or() converts both sides to Partial, and .matchPrevious() enforces at compile time that you only reference a name you’ve already captured. Understanding the type-level semantics of these four methods is the key to getting precise inference from .exec().

.group(builder)

Wraps the inner pattern in a non-capturing group ((?:...)). Captures defined inside the inner builder are merged into the outer TCaptures, so they remain accessible on the final result — the group itself does not introduce a new name. Signature
group<
  InnerCaptures extends Record<string, unknown>,
  InnerFlags extends Record<string, unknown>
>(builder: RegexBuilder<InnerCaptures, InnerFlags>): RegexBuilder<TCaptures & InnerCaptures, TFlags>
builder
RegexBuilder<InnerCaptures, InnerFlags>
required
A builder whose pattern will be wrapped in (?:...). Any captures defined on this builder are intersected into the outer type.
Example
import { rx } from '@fajarnugraha37/ts-rex';

const pattern = rx()
  .group(
    rx().capture('scheme', rx().literal('http').optional(rx().literal('s')))
  )
  .literal('://')
  .compile();

const result = pattern.exec('https://example.com');

if (result.isMatch) {
  console.log(result.scheme); // "https" — typed as string
}
The emitted regex for the .group() call is (?:(?<scheme>https?)). The scheme capture is fully available on the result even though it was defined inside a non-capturing group.

.capture(name, builder)

Wraps the inner pattern in a named capturing group ((?<Name>...)), adding Record<Name, string> to TCaptures. The name argument must be a valid JavaScript identifier; the runtime throws if it isn’t. Signature
capture<
  Name extends string,
  InnerCaptures extends Record<string, unknown>,
  InnerFlags extends Record<string, unknown>
>(
  name: Name,
  builder: RegexBuilder<InnerCaptures, InnerFlags>
): RegexBuilder<TCaptures & Record<Name, string> & InnerCaptures, TFlags>
name
string
required
The capture group name. Must match /^[a-zA-Z_][a-zA-Z0-9_]*$/ — a valid JavaScript identifier. Throws Error at runtime if invalid.
builder
RegexBuilder<InnerCaptures, InnerFlags>
required
The pattern to capture. Any captures nested inside this builder are also merged into TCaptures.
Name validation is enforced at runtime by a regex test: /^[a-zA-Z_][a-zA-Z0-9_]*$/. Names like "2fast" or "my-group" will throw. TypeScript does not prevent invalid names at compile time because Name extends string is unconstrained — validation is a runtime guard.
Type impact Each .capture() call intersects Record<Name, string> into TCaptures. After two captures, the type is:
RegexBuilder<
  Record<'firstName', string> & Record<'lastName', string>,
  DefaultFlags
>
This means the result of .exec() will have result.firstName: string and result.lastName: string when isMatch is true. Example
import { rx } from '@fajarnugraha37/ts-rex';

const pattern = 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 = pattern.exec('2024-03-15');

if (result.isMatch) {
  console.log(result.year);  // "2024"
  console.log(result.month); // "03"
  console.log(result.day);   // "15"
}
Nested captures Captures defined inside the builder argument are merged into the outer type alongside the outer capture name:
const inner = rx().capture('port', rx().oneOrMore(rx().digit()));

const pattern = rx()
  .capture('host', rx().oneOrMore(rx().wordChar()))
  .literal(':')
  .group(inner)
  .compile();

// result type includes both 'host': string and 'port': string

.or(builder)

Matches either the pattern accumulated so far or the pattern in the passed builder, emitting (?:left|right). At the type level, both TCaptures and the inner builder’s captures are converted to Partial, reflecting the fact that only one branch fires per match. Signature
or<
  OtherCaptures extends Record<string, unknown>,
  OtherFlags extends Record<string, unknown>
>(
  builder: RegexBuilder<OtherCaptures, OtherFlags>
): RegexBuilder<Partial<TCaptures> & Partial<OtherCaptures>, TFlags>
builder
RegexBuilder<OtherCaptures, OtherFlags>
required
The alternative branch. Its captures are merged as Partial into the result type.
Union semantics: .or() does not produce a TypeScript union (A | B). It produces an intersection of partials (Partial<A> & Partial<B>). This means every capture from both branches appears on the result type, but all are string | undefined. Use if (result.a !== undefined) guards to determine which branch matched. This is an intentional trade-off: discriminated unions on named captures are not expressible without knowing the branch at compile time.
Example
import { rx } from '@fajarnugraha37/ts-rex';

// Build a hex digit class safely using .range().or()
const hexDigit = rx()
  .range('0', '9')
  .or(rx().range('a', 'f'))
  .or(rx().range('A', 'F'));

const pattern = rx()
  .capture('hex', rx().literal('#').oneOrMore(hexDigit))
  .or(
    rx().capture('named', rx().oneOrMore(rx().wordChar()))
  )
  .compile();

const result = pattern.exec('#ff0000');

if (result.isMatch) {
  // Both 'hex' and 'named' are typed as string | undefined
  if (result.hex !== undefined) {
    console.log(result.hex); // "#ff0000"
  }
}
Chained alternations .or() is fully chainable, building up (?:(?:a|b)|c) style nesting:
const digits = rx().capture('d', rx().oneOrMore(rx().digit()));
const words  = rx().capture('w', rx().oneOrMore(rx().wordChar()));
const spaces = rx().capture('s', rx().oneOrMore(rx().whitespace()));

const tokenizer = rx()
  .group(digits.or(words).or(spaces))
  .global()
  .compile();

.matchPrevious(name)

Emits a backreference (\k<name>) that matches exactly the same text captured by a previously named group. The name argument is constrained to keyof TCaptures, so TypeScript raises a type error if you reference a group that hasn’t been captured yet in the chain. Signature
matchPrevious<Name extends keyof TCaptures>(name: Name): RegexBuilder<TCaptures, TFlags>
name
keyof TCaptures
required
The name of a previously defined capture group. TypeScript enforces this statically — passing an unknown name is a compile-time error.
Example
import { rx } from '@fajarnugraha37/ts-rex';

// Match an opening and closing HTML tag with the same name
const pattern = rx()
  .literal('<')
  .capture('tag', rx().oneOrMore(rx().wordChar()))
  .literal('>')
  .zeroOrMore(rx().anyChar())
  .literal('</')
  .matchPrevious('tag') // TypeScript: Name must be keyof { tag: string }
  .literal('>')
  .compile();

const result = pattern.exec('<div>hello</div>');

if (result.isMatch) {
  console.log(result.tag); // "div"
}
Static checking Attempting to reference a name not yet captured is a compile-time error:
rx()
  .literal('hello')
  .matchPrevious('missing');
//              ^^^^^^^^^
// Argument of type '"missing"' is not assignable to parameter
// of type 'keyof Record<string, never>'

Build docs developers (and LLMs) love