Skip to main content

Alternation and partial union types with .or()

Alternation means “match this pattern or that pattern.” In raw regex you write a|b. In TS-Rex you chain .or(otherBuilder). What makes TS-Rex’s approach distinctive is that the type system models the mutual exclusivity of the two branches: if branch A matched, branch B’s captures are undefined, and vice versa. This is represented by wrapping both sides in Partial.

A basic alternation

The simplest case has each branch containing a single named capture.

import { rx } from '@fajarnugraha37/ts-rex';

const pattern = rx()
.capture('a', rx().literal('A'))
.or(rx().capture('b', rx().literal('B')))
.compile();

const result = pattern.exec('A');

if (result.isMatch) {
// TypeScript knows both 'a' and 'b' might be undefined
console.log(result.a); // "A"
console.log(result.b); // undefined
}

The compiled pattern is (?:(?<a>A)|(?<b>B)). At runtime, a match against 'A' populates a and leaves b as undefined. A match against 'B' does the opposite.

How .or() computes the type

The .or() method signature on RegexBuilder is:

or<OtherCaptures>(
builder: RegexBuilder<OtherCaptures, OtherFlags>
): RegexBuilder<Partial<TCaptures> & Partial<OtherCaptures>, TFlags>

Both sides are wrapped in Partial. This is the correct model because the regex engine can only take one branch at a time — you cannot know at compile time which branch succeeded, so all captures from both branches become optional (string | undefined).

After an isMatch check you still have to narrow further if you want to treat a specific capture as definitely present:

if (result.isMatch) {
if (result.a !== undefined) {
// result.a is string here
console.log('Matched branch A:', result.a);
} else if (result.b !== undefined) {
// result.b is string here
console.log('Matched branch B:', result.b);
}
}

Building character ranges with .or()

.or() is also the correct way to compose character class alternatives when you need type-safe range composition. The auto-escaping rules mean you cannot inject raw range syntax like a-z into .anyOf() — instead you chain .range().or().

import { rx } from '@fajarnugraha37/ts-rex';

// Composes to: (?:(?:(?:[a-z]|[A-Z])|[0-9])|[.\-])
const alphanumericAndDot = rx()
.range('a', 'z')
.or(rx().range('A', 'Z'))
.or(rx().range('0', '9'))
.or(rx().anyOf('.-'));

Because none of these intermediate builders contain .capture(), all TCaptures states are Record<never, never> and the resulting Partial wrapping has no visible effect on the final type. The composition is purely structural.

A more complex alternation with multiple branches

You can chain .or() more than once to build multi-branch alternations. Each call wraps the accumulated left side in Partial again.

import { rx } from '@fajarnugraha37/ts-rex';

const pattern = rx()
.capture('hex', rx().literal('#').oneOrMore(rx().range('0', '9').or(rx().range('a', 'f'))))
.or(
rx().capture('rgb', rx().literal('rgb(').oneOrMore(rx().digit()).literal(')'))
)
.or(
rx().capture('named', rx().oneOrMore(rx().wordChar()))
)
.compile();

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

if (result.isMatch) {
// All three captures are string | undefined
console.log(result.hex); // "#ff0000"
console.log(result.rgb); // undefined
console.log(result.named); // undefined
}

The inferred type of the success branch is:

{
isMatch: true;
match: string;
hex?: string;
rgb?: string;
named?: string;
}

Note: The compiled regex for .or() uses non-capturing group wrapping: (?:left|right). The outer group ensures the alternation is properly delimited when other tokens follow.

Narrowing after .or()

Since all captures from an alternation are string | undefined, you narrow them the same way you would any optional property in TypeScript — a simple inequality check against undefined.