TypeScript’s generic type system is the engine behind TS-Rex’s safety guarantees. As you chain methods, the compiler tracks every named capture group you add, every quantifier that makes a group optional, and every flag that changes the execution return type — with no runtime overhead. By the time you callDocumentation 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.
.compile(), TypeScript already knows the exact shape of the object that .exec() will return. This page explains how each part of that inference works.
The TCaptures generic
RegexBuilder<TCaptures, TFlags> starts with an empty TCaptures equal to Record<string, never> (aliased as DefaultCaptures). Every call to .capture() intersects a new entry into TCaptures:
Record<Name, string> — a required string property keyed by the literal name you passed. Inner captures from a nested builder are merged in at the same time via InnerCaptures. This means the following chain:
CompiledRegex whose exec return type includes both firstName: string and lastName: string — visible in your IDE before you run a single test.
The TFlags generic
TFlags starts as Record<string, never> and grows as you add flags. Each flag method uses an intersection with Omit to prevent duplicate keys:
MatchResult, described below. The actual runtime flag string is assembled separately in _getFlagsString() — the TypeScript types and the runtime value are kept in sync but computed independently.
The MatchResult discriminated union
The exec method on CompiledRegex returns MatchResult<TCaptures, TFlags>, which is a conditional type:
TFlags does not contain { global: true }, the result is a discriminated union on the isMatch boolean:
if (result.isMatch) gives TypeScript enough information to infer SingleMatch, making all capture properties available as string. In the else branch, TypeScript knows you have FailedMatch and all capture properties are undefined. You never need to check for null on individual groups.
Global mode: IterableIterator
Adding .global() shifts TFlags to contain { global: true }, which flips MatchResult to IterableIterator<SingleMatch<TCaptures, TFlags>>. There is no union with FailedMatch in this branch — each yielded item is already a successful match:
Indices mode: withIndices
Adding .withIndices() sets { hasIndices: true } in TFlags. The conditional inside SingleMatch then merges an indices object into the result type, giving each group a [number, number] tuple:
Partial captures from quantifiers and alternation
Not every capture group is guaranteed to be present in a match. TS-Rex models this precisely..optional() and .zeroOrMore()
Both quantifiers wrap inner captures in Partial<InnerCaptures> at the type level:
.optional() wrapper become string | undefined in the result, reflecting that the group may simply not participate in a given match.
.or(): mutual exclusivity
.or() models alternation where exactly one branch matches. It wraps both sides in Partial:
undefined to determine which branch matched:
Why does .or() make the left side Partial too?
Why does .or() make the left side Partial too?
When you write
builderA.or(builderB), neither branch is guaranteed to match. The alternation wraps both in a non-capturing group ((?:...|...)), and the regex engine picks one. TypeScript has no way to know at the call site which branch will win, so both sides are typed as optional. This is conservative but correct — you always narrow at runtime with an !== undefined check.What about .atLeast(0) and .between(0, n)?
What about .atLeast(0) and .between(0, n)?
Both have the same optionality semantics as When
.zeroOrMore(). The type-level condition is checked on the Min or N generic parameter:n is the literal 0, TypeScript resolves the conditional to Partial<InnerCaptures>. Any other numeric literal leaves captures required.