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) // falseBoth 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) // trueThe 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.
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:
- A character description -- what kind of character you want
- A repetition -- how many times in a row
Take [a-z]+[0-9]+:
[a-z]is "any lowercase letter"+means "one or more"[0-9]is "any digit"+again, "one or more"
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:
\d-- any digit (same as[0-9])\w-- any "word" character: letter, digit, or underscore (same as[a-zA-Z0-9_])\s-- any whitespace (space, tab, newline)
Capitalize the letter to invert the meaning:
\D-- anything that is not a digit\W-- anything that is not a word character\S-- anything that is not whitespace
For finer control, build your own classes:
[a-z],[0-9],[m-r],[4-8]-- ranges[AEIOUaeiou]-- an explicit setA|E|I|O|U|a|e|i|o|u-- the|"or" operator, useful for multi-character alternatives likes|ed|ing.-- any character at all (use sparingly; it matches more than you think)
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:
+-- one or more?-- zero or one (optional)*-- zero or more{n,}-- at leastntimes{n,m}-- betweennandmtimes
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))
}
// abc123Only 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
// 1The 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
// abcBetter -- 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}ABCThe 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.
\b-- a word boundary (the edge between a word character and a non-word character)^-- start of a line$-- end of a line
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
// 123Four 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 RegexBuilderNow 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:
\d→.digit\w→.word\s→.whitespace\D→.digit.inverted\W→.word.inverted\S→.whitespace.inverted.→.any\b→Anchor.wordBoundary^→Anchor.startOfLine$→Anchor.endOfLine[a-z]→"a"..."z"[AEIOUaeiou]→CharacterClass.anyOf("AEIOUaeiou")|→ChoiceOf { ... }+→OneOrMore { ... }?→Optionally { ... }*→ZeroOrMore { ... }{n,}→Repeat(n...) { ... }{n,m}→Repeat(n...m) { ... }
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.
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
// 789Challenge 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
- Regular expressions describe patterns of characters rather than exact strings. They unlock matches that simple substring search can't express.
- Swift regex literals -- written between
/s -- are validated by the compiler. Bad patterns fail to build, not at runtime. - Use character classes (
\d,\w,\s, custom ranges) plus repetitions (+,?,*,{n,},{n,m}) to build matches. - Patterns that allow "zero of something" can match empty positions. Use
+(or anchor with\b,^,$) to avoid surprising zero-length results. RegexBuilderlets you write the same patterns as Swift code. It's wordier, but far easier to read, autocomplete, and refactor.- Use
Captureto pull pieces out of a match, andTryCaptureto convert them to other types (likeInt) in the same step. - A
Captureinside a repetition stores only the last iteration's value. Move the repetition outside if you need every value.
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.
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