Tutorials Ultimate Swift Series Chapter 12

Regex

SwiftChapter 12 of the Ultimate Swift Series35 minMay 15, 2026Intermediate

When you scan a block of text for proper names, dates, or URLs, you're not looking for exact characters -- you're spotting patterns. A date is "three groups of numbers separated by slashes." A URL "starts with http and has dots and slashes." You don't need to know the values in advance; the shape is enough.

Swift gives you the same superpower with regular expressions (regex for short). This chapter shows you both the classic literal syntax and Swift's newer RegexBuilder DSL, which makes complex patterns readable.

Your First Regex

In Swift, you write a regex literal between forward slashes instead of quotes:

let searchString = "john"
let searchExpression = /john/

String has a contains() method that accepts either:

let stringToSearch = "Johnny Appleseed wants to change his name to John."
 
stringToSearch.contains(searchString)      // false
stringToSearch.contains(searchExpression)  // false

Both return false, which might surprise you -- there are clearly two instances of "John" in the text. The catch: regex is case-sensitive by default, and uppercase J is a different character than lowercase j.

You can fix this by describing the pattern more flexibly:

let flexibleExpression = /[Jj]ohn/
stringToSearch.contains(flexibleExpression)  // true

The brackets [Jj] say "either an uppercase or lowercase J," followed by the literal text ohn. That tiny addition is the whole point of regex: mix static characters with descriptors that match a kind of character.

Regex is everywhere in String

Most String methods that accept a search pattern accept a regex too: trimmingPrefix(), replacing(_:with:), firstMatch(of:), matches(of:). Anywhere you used a String for matching, you can use a regex.

Anatomy of a Pattern

A regular expression is two ideas glued together:

  1. A character description -- what kind of character you want
  2. A repetition -- how many times in a row

Take [a-z]+[0-9]+:

So the whole pattern reads as "one or more lowercase letters, followed by one or more digits." It matches swiftapprentice2025 but not XYZ567 (uppercase letters) and not Pennsylvania65000 (starts uppercase).

Character Classes

Several backslash escapes stand in for common character groups:

Capitalize the letter to invert the meaning:

For finer control, build your own classes:

Ranges follow Unicode order

Ranges like [5-d] are valid -- they span digits, uppercase letters, and the start of lowercase letters, because that's the Unicode ordering. You almost never want this. Stick to ranges within a single character group: [a-z], [A-Z], [0-9].

Repetitions

Repetition modifiers go right after the thing they repeat:

So + is shorthand for {1,}, and * is shorthand for {0,}.

Mini-exercise

Adapt /[a-z]+[0-9]+/ to also match XYZ567 and Pennsylvania65000 -- mixed-case words followed by digits. Hint: include uppercase letters in the character class.

Compile-Time Checking

Most languages catch regex mistakes at runtime, when your code is already in production. Swift catches them at compile time:

let lowercaseLetters = /[a-z*/   // ⛔ Compiler error: missing ]

Adding the missing bracket fixes it:

let lowercaseLetters = /[a-z]*/  // ✅

This single feature -- regex literals validated by the Swift compiler -- is one of the biggest reasons to prefer Swift regex over passing strings to a runtime regex API.

Finding Matches

contains() only tells you yes or no. To get the actual matches, use matches(of:). It returns an array of match objects with two key properties: .output (the matched substring) and .range (where it sits in the original string).

let lettersAndNumbers = /[a-z]+[0-9]+/
let testingString1 = "abcdef ABCDEF 12345 abc123 ABC 123 123ABC 123abc abcABC"
 
for match in testingString1.matches(of: lettersAndNumbers) {
    print(String(match.output))
}
// abc123

Only one match. The string contains abcdef, 12345, 123, and other tokens, but the pattern requires lowercase letters and then digits, with at least one of each. Only abc123 qualifies.

The Zero-Length Trap

Change + to * and the match count explodes:

let possibleLettersAndPossibleNumbers = /[a-z]*[0-9]*/
 
for match in testingString1.matches(of: possibleLettersAndPossibleNumbers) {
    print(String(match.output))  // prints 32 times
}

Why so many? * means zero or more. So "zero letters followed by zero digits" -- the empty string -- is a valid match. The regex engine finds an empty match at almost every position.

To prove it, try the pattern on an empty string:

let emptyString = ""
let count = emptyString.matches(of: possibleLettersAndPossibleNumbers).count
// 1

The engine still finds one match: the empty string itself.

Avoiding zero-length matches

The fix is to write the pattern so it always requires at least one real character. Use | to split it into two alternatives, each with a +:

let fixed = /[a-z]+[0-9]*|[a-z]*[0-9]+/

This reads as "one or more letters then optional digits, OR optional letters then one or more digits." Either side guarantees at least one matched character.

for match in testingString1.matches(of: fixed) {
    print(String(match.output))
}
// abcdef
// 12345
// abc123
// 123
// 123
// 123
// abc
// abc

Better -- but eight matches instead of the four you probably wanted. Look at the source string with the matches braced:

{abcdef} ABCDEF {12345} {abc123} ABC {123} {123}ABC {123}{abc} {abc}ABC

The engine is matching the lowercase fragments of 123ABC, 123abc, and abcABC. They're technically valid -- you just want whole words.

Anchors

Anchors are zero-width assertions about where a match can occur. They don't consume characters; they declare position.

Wrap the previous pattern with \b on both sides:

let fixedWithBoundaries = /\b[a-z]+[0-9]*\b|\b[a-z]*[0-9]+\b/
 
for match in testingString1.matches(of: fixedWithBoundaries) {
    print(String(match.output))
}
// abcdef
// 12345
// abc123
// 123

Four matches, exactly the whole tokens you'd expect.

Challenge 1

Write a regex that matches any word containing two or more uppercase letters in a row. It should match 123ABC, ABC123, ABC, abcABC, ABCabc, abcABC123, and a1b2ABCDEc3d4 -- but reject abcA12a3 and abc123.

Hints: A character class can contain multiple ranges, e.g. [a-z0-9]. Use {2,} for "two or more." Wrap with \b to require whole words.

RegexBuilder: a Readable Alternative

Classic regex syntax is compact, but reading it later -- especially someone else's -- can feel like decoding hieroglyphs. Swift offers a second way: a result-builder DSL called RegexBuilder.

Import it at the top of the file:

import RegexBuilder

Now rewrite [a-z]+[0-9]+ like this:

let newLettersAndNumbers = Regex {
    OneOrMore { "a"..."z" }
    OneOrMore { .digit }
}

Functionally identical to the literal -- but you can read it out loud. Here's the full mapping from regex syntax to RegexBuilder:

The character-class shortcuts (.digit, .word, etc.) are members of CharacterClass. The full name is CharacterClass.digit, but type inference usually lets you drop the prefix.

Here's the boundary-anchored "letters and numbers" pattern from earlier, rewritten:

let newFixedRegex = Regex {
    Anchor.wordBoundary
    ChoiceOf {
        Regex {
            OneOrMore { "a"..."z" }
            ZeroOrMore { .digit }
        }
        Regex {
            ZeroOrMore { "a"..."z" }
            OneOrMore { .digit }
        }
    }
    Anchor.wordBoundary
}

Notice how Anchor.wordBoundary lives outside ChoiceOf, so it applies to both alternatives -- much clearer than the duplicated \b in the literal version.

Refactor existing regex into RegexBuilder

Xcode can convert a regex literal into RegexBuilder form for you. Put your cursor inside any regex literal, right-click, and pick Refactor ▸ Convert to Regex Builder. Great for understanding inherited patterns or migrating older code.

Challenge 2

Convert your Challenge 1 regex into RegexBuilder form, and make it match strings with multiple runs of uppercase letters -- for example a1b2ABCDEc3d4FGHe5f6g7.

Hint: To combine character classes, use CharacterClass.digit.union("a"..."z").

Capturing Results

A match tells you what matched. A capture lets you pull out specific pieces of the match for further use.

In literal syntax, wrap the part you want to capture in parentheses:

let regex = /[a-z]+(\d+)[a-z]+/

In RegexBuilder, wrap it in Capture:

let regexWithCapture = Regex {
    OneOrMore { "a"..."z" }
    Capture {
        OneOrMore { .digit }
    }
    OneOrMore { "a"..."z" }
}

The output type changes from a plain Substring to a tuple. The first element is always the full match; each capture adds another element. One capture → 2-element tuple. Five captures → 6-element tuple.

let testingString2 = "welc0me to chap7er 10 in sw1ft appren71ce. " +
    "Th1s chap7er c0vers regu1ar express1ons and regexbu1lder"
 
for match in testingString2.matches(of: regexWithCapture) {
    print(match.output)
}
// ("elc0me", "0")
// ("chap7er", "7")
// ("sw1ft", "1")
// ("appren71ce", "71")
// ...

Destructure the tuple right at the loop site for cleaner code:

for match in testingString2.matches(of: regexWithCapture) {
    let (fullMatch, digits) = match.output
    print("Full: \(fullMatch) | Digits: \(digits)")
}

TryCapture: Transform While You Capture

Captures are always Substring by default. If you want them as another type -- say, Int -- use TryCapture with a transform closure:

let regexWithStrongType = Regex {
    OneOrMore { "a"..."z" }
    TryCapture {
        OneOrMore { .digit }
    } transform: { foundDigits in
        Int(foundDigits)
    }
    OneOrMore { "a"..."z" }
}

Now the second tuple element is an Int, not a Substring:

// ("elc0me", 0)
// ("chap7er", 7)
// ("appren71ce", 71)

The "Try" in TryCapture is literal: if the closure returns nil (e.g. Int("abc") fails), the entire match is discarded. That's a feature -- it lets you treat regex matching and parsing as a single step.

The "Last Match Wins" Gotcha

Captures don't accumulate across repetitions. If a Capture block sits inside a OneOrMore, you get exactly one value -- the one from the last iteration.

let repetition = "123abc456def789ghi"
 
let repeatedCaptures = Regex {
    OneOrMore {
        Capture {
            OneOrMore { .digit }
        }
        OneOrMore { "a"..."z" }
    }
}
 
for match in repetition.matches(of: repeatedCaptures) {
    print(match.output)
}
// ("123abc456def789ghi", "789")

You might expect three captures: "123", "456", "789". You get one: "789", the last value the Capture block saw.

To collect every digit run, lift the repetition outside and let matches(of:) give you multiple match objects:

let everyRun = Regex {
    Capture { OneOrMore { .digit } }
    OneOrMore { "a"..."z" }
}
 
for match in repetition.matches(of: everyRun) {
    print(match.output.1)
}
// 123
// 456
// 789

Challenge 3

Extend your Challenge 2 regex to capture the uppercase runs. If the input has many uppercase runs, capture up to three of them.

Key Points

Next up, you'll move from working with Swift's built-in types to defining your own. In Chapter 13: Structures, you'll learn how to bundle related values together into a single named type and give that type behavior of its own.

Ch 11: Strings Deep DiveCh 13: Structures
SwiftUIUltimate SwiftUI SeriesSwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.DeliveryModern Delivery PipelineCI/CD, review, runner, and deploy workflows for teams shipping apps and websites safely.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access