Swift RegexBuilder: The Complete Guide to Type-Safe Regular Expressions in Swift 6

Swift RegexBuilder gives you type-safe regular expressions with named captures, inline transforms, and Unicode-correct matching by default. Learn the DSL, compare it to NSRegularExpression, and avoid production traps.

Swift RegexBuilder: Swift 6 Guide (2026)

Updated: July 3, 2026

Swift RegexBuilder is a result-builder DSL that lets you compose regular expressions as strongly-typed Swift code instead of writing opaque pattern strings, giving you compile-time checking, named captures with real Swift types, and full interop with Swift's Regex<Output> engine. Introduced in Swift 5.7 and stabilized through Swift 6, it replaces most day-to-day uses of NSRegularExpression. Honestly, I've been writing text processing in Swift since the NSRegularExpression days, and RegexBuilder is the first API I've genuinely enjoyed. This guide covers the DSL, literals, captures, migration, and the traps you only hit in production.

  • RegexBuilder produces a Regex<Output> whose Output tuple encodes every capture, so mismatched captures fail at compile time rather than runtime.
  • You can freely mix regex literals (/\d+/), string patterns, and DSL blocks; they all conform to RegexComponent.
  • Reference<T> gives you named captures with a real Swift type instead of stringly-typed group indexes.
  • TryCapture lets a capture fail (and backtrack) when its transform throws or returns nil. Use it to parse integers, dates, or enums inline.
  • RegexBuilder patterns are anchor-aware and Unicode-correct by default; you rarely need to reach for NSRegularExpression anymore.
  • The RegexBuilder module is part of the standard library on iOS 16+, macOS 13+, and ships with every current Xcode toolchain.

What is Swift RegexBuilder?

RegexBuilder is a @resultBuilder-based DSL, imported from the RegexBuilder module, that describes a regular expression as a tree of Swift values. Each component (OneOrMore, Optionally, Capture, ChoiceOf, an anchor, a character class, or a plain string literal) conforms to the RegexComponent protocol. When the builder finishes, the compiler synthesises a concrete Regex<Output> where Output is a tuple whose first element is the full match and whose remaining elements are the strongly-typed captures.

The practical difference from a Perl-style pattern string is that mistakes surface as build errors. A pattern like "(\\d+)-(\\w+)" is just a String; if you try to read group 3, you get a runtime nil. In RegexBuilder, adding or removing a Capture changes the Output tuple's arity, so the code that consumes it stops compiling. Combined with Swift's typed throws, this pushes an entire class of parsing bugs to compile time.

RegexBuilder is not a separate engine. Underneath it lives the same matching runtime as regex literals, which since Swift 5.7 has been an in-tree engine written in Swift itself. Whichever form you write (DSL, literal, or a runtime string via Regex(_:)), the executor and the Regex<Output> API are identical.

Regex literals vs. the RegexBuilder DSL

Swift supports three ways to spell a regex. Regex literals sit inside forward slashes: /\d+/. Extended literals use #/.../# when the pattern contains slashes or ambiguous punctuation. And the DSL uses a builder block: Regex { OneOrMore(.digit) }. All three produce a Regex<Output> value and can be freely combined.

So, when should you use each? Use a literal when the pattern is short, well-known, and fits on a line. Use the DSL when the pattern has more than one capture, when you want named references, or when the pattern is long enough that whitespace and comments improve readability. Here's the same email-shaped pattern in both forms:

import RegexBuilder

// Literal form
let emailLiteral = /([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/

// DSL form
let user = Reference(Substring.self)
let host = Reference(Substring.self)

let emailDSL = Regex {
    Capture(as: user) {
        OneOrMore(.word.union(.anyOf("._%+-")))
    }
    "@"
    Capture(as: host) {
        OneOrMore(.word.union(.anyOf(".-")))
        "."
        Repeat(.word, count: 2...)
    }
}

if let match = "[email protected]".wholeMatch(of: emailDSL) {
    print(match[user])  // "lukas"
    print(match[host])  // "swiftcrafted.dev"
}

Notice that the DSL uses Reference, so callers subscript the match with a typed key rather than a group index. Adding a new capture in the middle of the pattern will not shift any of the existing references, which is a huge quality-of-life win over index-based groups in Foundation's regex.

How do you capture groups with RegexBuilder?

A Capture { ... } block records the substring that its body matched and adds a slot to the Output tuple. Without a Reference, captures are addressed positionally: match.1, match.2, and so on. With a Reference<T>, you index the match by the reference value itself, and the compiler enforces the type. In practice, references win any time you have more than one capture.

Here's a real-world parser I've used to extract semantic version tuples out of a changelog:

let major = Reference(Int.self)
let minor = Reference(Int.self)
let patch = Reference(Int.self)

let semver = Regex {
    Anchor.wordBoundary
    "v"
    TryCapture(as: major) { OneOrMore(.digit) } transform: { Int($0) }
    "."
    TryCapture(as: minor) { OneOrMore(.digit) } transform: { Int($0) }
    "."
    TryCapture(as: patch) { OneOrMore(.digit) } transform: { Int($0) }
    Anchor.wordBoundary
}

let line = "Release v6.2.1 shipped today"
if let m = line.firstMatch(of: semver) {
    let version = (m[major], m[minor], m[patch])   // (6, 2, 1), all Ints
    print("Parsed version: \(version)")
}

Notice that major, minor, and patch are typed as Int, not Substring. That's TryCapture doing its job: it applies the transform during matching, and if the transform returns nil the engine backtracks as if the capture had failed to match at all. That's genuinely different from post-processing a substring after the match. A rejected transform can cause a different alternative to be tried instead.

If you don't need typed values, use Capture without a transform. The default output is a Substring, which is cheap to hold onto because it shares storage with the input string.

TryCapture and transforming captures inline

TryCapture is the workhorse for turning matched text into domain values. The transform closure receives the captured substring and returns T?. Return nil to reject the match and force the engine to explore other alternatives. That behaviour is why TryCapture is different from calling Int($0) after the fact, because the failure participates in matching.

enum LogLevel: String { case debug, info, warn, error }

let level = Reference(LogLevel.self)
let message = Reference(Substring.self)

let logLine = Regex {
    "["
    TryCapture(as: level) {
        ChoiceOf { "debug"; "info"; "warn"; "error" }
    } transform: { LogLevel(rawValue: String($0)) }
    "] "
    Capture(as: message) {
        OneOrMore(.any)
    }
}

let entry = "[warn] disk almost full"
if let m = entry.wholeMatch(of: logLine) {
    print(m[level])    // LogLevel.warn
    print(m[message])  // "disk almost full"
}

A subtle detail: because RegexBuilder is Unicode-correct by default, .any matches an extended grapheme cluster, not a UTF-16 code unit. If you're validating input that contains flag emoji or family sequences, you get the answer humans expect. If you specifically want code-unit semantics (for interop with a legacy pattern), call .matchingSemantics(.unicodeScalar) on the regex.

Character classes, anchors, and quantifiers

RegexBuilder ships a rich set of typed character classes on the CharacterClass type. The common ones map onto Perl-ish syntax exactly as you'd expect: .digit, .word, .whitespace, .hexDigit, .newlineSequence. What Perl calls . is .any, and negation is a leading ! on .anyOf or a call to .inverted. Union and subtraction let you compose new classes:

let vowel: CharacterClass = .anyOf("aeiouAEIOU")
let consonant = CharacterClass.word.subtracting(vowel)

let identifier = Regex {
    CharacterClass.word.subtracting(.digit)   // first char can't be a digit
    ZeroOrMore(.word)
}

Quantifiers are their own components: OneOrMore, ZeroOrMore, Optionally, and Repeat(count:) / Repeat(_:). Each takes an optional behaviour parameter: .eager (default), .reluctant, or .possessive. Possessive quantifiers disable backtracking for their subpattern; use them when you know a match is committed and want to keep the engine from wandering. Anchor components (Anchor.startOfLine, Anchor.endOfSubject, Anchor.wordBoundary) let you constrain where a pattern is allowed to match. They read better than the equivalent ^/$/\b in a literal, especially in long DSL blocks.

You can also nudge the whole regex with modifiers: .ignoresCase(), .dotMatchesNewlines(), .anchorsMatchLineEndings(). These correspond to the flags you'd set in a literal ((?i), (?s), (?m)) but are visible in the Swift call chain, which is usually easier to review.

ChoiceOf: alternation the Swift way

ChoiceOf is the DSL's answer to the pipe (|) operator. Inside the block, each statement is one alternative. Alternatives are tried in source order, so put the most specific option first when they share a prefix. Otherwise the engine will commit to the shorter match and stop before it gets to yours. Here's a canonical HTTP-method parser:

enum HTTPMethod: String { case GET, POST, PUT, PATCH, DELETE }

let method = Reference(HTTPMethod.self)
let path = Reference(Substring.self)

let requestLine = Regex {
    Anchor.startOfLine
    TryCapture(as: method) {
        ChoiceOf {
            "GET"
            "POST"
            "PUT"
            "PATCH"
            "DELETE"
        }
    } transform: { HTTPMethod(rawValue: String($0)) }
    " "
    Capture(as: path) {
        OneOrMore(.whitespace.inverted)
    }
    " HTTP/1."
    One(.digit)
}

Because RegexBuilder builds the pattern from concrete Swift values, you can generate alternatives programmatically:

let acceptedLocales = ["en-US", "en-GB", "de-DE", "ja-JP"]

let localeChoice = ChoiceOf {
    ComponentBuilder.buildArray(acceptedLocales.map { $0 })
}

That "generate the pattern in Swift" style closes a gap that NSRegularExpression forces you to fill by string-mashing, with all the escaping traps that come with it. If you're building a router that maps your networking layer's URL patterns to handlers, you can express the routes in RegexBuilder directly.

Swift Regex vs. NSRegularExpression

The elevator-pitch answer is: prefer Regex<Output> for any new code, and keep NSRegularExpression only where you have to interoperate with an Objective-C API that expects it. But the details matter, so here's the side-by-side comparison I keep in my head:

FeatureSwift Regex / RegexBuilderNSRegularExpression
Type safetyCaptures typed via Output tuple / Reference<T>Groups indexed by integer, results are NSRange
SyntaxPerl-compatible literals + Swift DSLICU regex (Perl-ish, minor differences)
UnicodeGrapheme-cluster semantics by defaultUTF-16 code-unit semantics
Substring indexingNative String.IndexRequires NSRangeRange<String.Index> conversion
Runtime patternstry Regex(pattern)NSRegularExpression(pattern:)
AvailabilityiOS 16+ / macOS 13+iOS 4+ / macOS 10.7+
ComposabilityValues of RegexComponent combine freelyString concatenation only
Compile-time checkYes (literals and DSL)No, syntax errors are runtime

The most common migration hiccup is Unicode semantics. I hit this exact bug shipping an SMS validator: if you have an existing NSRegularExpression pattern that counts code units, say, checking a phone number is "exactly 10 characters", the Swift version will count grapheme clusters and give different results for exotic input. Add .matchingSemantics(.unicodeScalar) to reproduce the old behaviour before you flip a switch in production. The official Regex documentation covers every option.

Building your own RegexComponent

Because everything in the DSL is a value conforming to RegexComponent, you can package a reusable subpattern as a struct with its own type and captures. This is how I share things like ISO date parsing across a codebase without copying and pasting the pattern:

struct ISO8601Date: RegexComponent {
    let year = Reference(Int.self)
    let month = Reference(Int.self)
    let day = Reference(Int.self)

    var regex: Regex<(Substring, Int, Int, Int)> {
        Regex {
            TryCapture(as: year)  { Repeat(.digit, count: 4) } transform: { Int($0) }
            "-"
            TryCapture(as: month) { Repeat(.digit, count: 2) } transform: { Int($0) }
            "-"
            TryCapture(as: day)   { Repeat(.digit, count: 2) } transform: { Int($0) }
        }
    }
}

let date = ISO8601Date()
let line = "shipped 2026-07-03 at midnight"
if let m = line.firstMatch(of: date) {
    print(m[date.year], m[date.month], m[date.day])  // 2026 7 3
}

The custom component is a first-class citizen. You can drop it into another Regex { ... } block, quantify it with OneOrMore, or wrap it in a Capture. That kind of composition is the whole point of the DSL. I keep a small library of these in every Swift package I ship.

Performance and production gotchas

Swift's regex engine is a hybrid: it starts with an NFA-based interpreter and can promote to a DFA for subpatterns it can prove don't backtrack. In practice, DSL-authored regexes are competitive with, and often faster than, NSRegularExpression on the same input, especially on long strings where the code-unit-to-String.Index conversion overhead of the ObjC bridge adds up.

That said, there are three traps I've hit in production. First, catastrophic backtracking is still possible. A pattern like OneOrMore { OneOrMore(.word) } on non-matching input can blow up exponentially. Use possessive quantifiers or atomic groups to cap backtracking when the pattern is committed. Second, Regex(_: String) throws at runtime because it parses the pattern; if you take the string from configuration or user input, wrap it in a proper error path rather than try!. Third, be careful with .wholeMatch(of:) versus .firstMatch(of:) versus .matches(of:). Whole-match anchors the pattern to both ends of the input, which is often what you want for validation but almost never what you want for extraction.

For heavy pipelines (log processing, rich text editors, source code tooling), profile the actual pattern with Instruments' Time Profiler and cache the Regex value. Constructing a Regex from a builder is not free, and repeated construction inside a loop is a common source of avoidable overhead. Reading the swift-experimental-string-processing repository is also worthwhile if you want to understand what the engine can and can't optimise.

Frequently Asked Questions

Is Swift Regex faster than NSRegularExpression?

Usually yes, especially on Swift-native String input, because Swift Regex avoids the NSRangeString.Index bridging cost and uses a hybrid NFA/DFA engine written directly in Swift. On short patterns the difference is small; on long inputs it can be several times faster.

Which Swift version introduced RegexBuilder?

Swift 5.7, shipped with Xcode 14 in September 2022. It became available on iOS 16, macOS 13, tvOS 16, and watchOS 9. Later Swift versions (6.0+) added refinements like better ChoiceOf ergonomics and improved diagnostics, but the core API is stable.

Can I use RegexBuilder with older iOS versions?

The DSL types are only available on iOS 16 and later. If you must ship to iOS 15 or earlier, you can still use regex literal syntax for simple patterns compiled to NSRegularExpression under the hood, or keep using NSRegularExpression directly. Gate any RegexBuilder call site with if #available(iOS 16, *).

How do I convert a regex match to a Swift Int or Date?

Use TryCapture with a transform closure that returns the target type. For dates specifically, capture the components and construct a Date via Calendar, or capture the whole ISO string and parse it with Date.ISO8601FormatStyle after the match. Failing transforms cause the match to fail cleanly.

Does Swift Regex support named captures?

Yes, via Reference<T>. You declare a reference, pass it to Capture(as:) or TryCapture(as:), then subscript the match with the reference. Unlike stringly-named groups in Perl-style regex, references are typed and checked at compile time.

Lukas Müller
About the Author Lukas Müller

iOS developer and Swift author since the Objective-C days. Spends his evenings on side projects and his mornings on SwiftUI internals.