Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 098 / 100

Swift string handling: a Swift & iOS guide for developers

Unicode-correct text.

Expona Admin7 min read

Unicode-correct text handling in Swift

String handling can be deceptively complex in any language, but Swift forces you to think carefully about what you're actually doing. When we talk about Swift string handling: a Swift & iOS guide for developers, we're not just discussing concatenation and basic text manipulation—we're addressing Unicode correctness, performant iteration, and proper text processing that works reliably across all languages and character sets your users might throw at you.

Most developers learn that strings are simple: grab a character, process it, move on. In reality, Swift strings are designed to be Unicode-correct by default, which means they handle emoji, combining characters, and international text properly—but you need to understand how they work to leverage that power effectively.

Strings are collections of Characters

In Swift, a String is a collection of Character values, not bytes or code units. This distinction matters profoundly because a Character can represent a single user-perceived character, which might be composed of multiple Unicode scalars. When you iterate over a string, you're iterating over actual characters the user sees, not arbitrary code units.

Consider this practical example: the flag emoji 🇺🇸 is visually a single character but consists of two regional indicator scalars combined together. If you were working with raw code units, you'd need fragile logic to handle these correctly. Swift's Character type handles this for you.

swift
let greeting = "Hello, 世界! 🇺🇸"
let characterCount = greeting.count  // 10 characters

for char in greeting {
    print(char)
}
// Prints: H, e, l, l, o, comma, space, 世, 界, space, 🇺🇸

This works correctly because String conforms to Collection, and iterating gives you proper grapheme clusters—the user-perceived characters. In a production app handling user-generated content across multiple markets, this is invaluable. You're not accidentally splitting emoji or corrupting combining diacritics.

The practical benefit: if you're building a messaging app, social platform, or any text-heavy feature, iteration over strings just works correctly for international users without special-case handling.

Indexes are not integers

This is where many developers stumble. Coming from other languages, you might expect string[0] to work. It doesn't. Swift strings don't support integer indexing because calculating the position of the nth character requires traversing the string—you can't jump to an arbitrary position without knowing the byte layout of every character before it.

String indexes in Swift are String.Index values obtained through specific methods. This design choice is deliberate: it prevents you from accidentally writing O(n) code hidden inside what looks like O(1) operations.

swift
let text = "Development"

// Get the first character
let firstIndex = text.startIndex
let firstChar = text[firstIndex]  // "D"

// Get the last character
let lastIndex = text.index(before: text.endIndex)
let lastChar = text[lastIndex]  // "t"

// Advance by 3 positions
let thirdIndex = text.index(firstIndex, offsetBy: 3)
let thirdChar = text[thirdIndex]  // "e"

// Find a substring and get its index
if let searchIndex = text.firstIndex(of: "l") {
    let beforeL = text[..

In a real scenario, suppose you're processing logs or parsing API responses. You need to extract a substring after a certain token. Using integer arithmetic leads to bugs when strings contain multi-byte characters. Using String.Index is the correct approach:

swift
func extractAfterColon(_ text: String) -> String? {
    guard let colonIndex = text.firstIndex(of: ":") else { return nil }
    let afterColon = text.index(after: colonIndex)
    return String(text[afterColon...]).trimmingCharacters(in: .whitespaces)
}

let logLine = "timestamp: 2024-01-15T09:30:00Z"
if let value = extractAfterColon(logLine) {
    print(value)  // "2024-01-15T09:30:00Z"
}

The performance model here is transparent: you see the iteration happening. When you use index(offsetBy:), it's clear that you're doing O(n) work. This prevents silent performance regressions in code reviews.

Use Regex builders for parsing

Swift 5.7 introduced regex literals and regex builders, which allow type-safe pattern matching without stringly-typed regex syntax. For developers parsing structured text—JSON responses, user input validation, log parsing—this is a game changer because the compiler validates your patterns at compile time.

Traditional regex strings are fragile: a typo goes undetected until runtime. Regex builders catch these errors immediately.

swift
import RegexBuilder

let datePattern = Regex {
    Capture {
        OneOrMore(.digit)
    }
    "-"
    Capture {
        OneOrMore(.digit)
    }
    "-"
    Capture {
        OneOrMore(.digit)
    }
}

let text = "The meeting is scheduled for 2024-01-15 at 3 PM"
if let match = text.firstMatch(of: datePattern) {
    print(match.output.1)  // "2024"
    print(match.output.2)  // "01"
    print(match.output.3)  // "15"
}

For real-world use, imagine you're building a CRM system that ingests contact data from multiple sources. Phone numbers, email addresses, and dates come in various formats. A regex builder makes parsing reliable:

swift
let emailPattern = Regex {
    Capture {
        OneOrMore(.word)
        ZeroOrMore {
            CharacterClass(".", "_", "-")
            OneOrMore(.word)
        }
    }
    "@"
    Capture {
        OneOrMore(.word)
        ZeroOrMore {
            "."
            OneOrMore(.word)
        }
    }
}

let input = "contact: alice.smith@company.co.uk"
if let match = input.firstMatch(of: emailPattern) {
    let localPart = String(match.output.1)
    let domain = String(match.output.2)
    print("\(localPart) at \(domain)")
}

You also get compile-time safety: if your regex syntax is invalid, the build fails. This prevents runtime crashes in production when a developer makes a subtle regex mistake.

FormatStyle for numbers and dates

Formatting numbers and dates correctly across locales is tedious and error-prone. FormatStyle, introduced in iOS 15, centralizes this logic and makes it readable. Rather than manually constructing date format strings or worrying about decimal separators in different regions, you declare your formatting intent clearly.

For a financial app or any system dealing with currency, locale-aware formatting isn't optional—it's critical for user trust and correctness.

swift
import Foundation

let amount = 1234.567
let formatted = amount.formatted(.currency(code: "USD"))
print(formatted)  // "$1,234.57" in US locale, "1 234,57 $" in French locale

let date = Date()
let dateFormatted = date.formatted(date: .abbreviated, time: .standard)
print(dateFormatted)  // "Jan 15, 2024, 3:30:45 PM" in US, varies by locale

let customNumber = 42.0
let percent = customNumber.formatted(.percent)
print(percent)  // "4,200%" — multiply by 100 automatically

In a trading platform or e-commerce app, this is invaluable. You format numbers once, and they automatically adapt to the user's locale without additional logic:

swift
struct OrderSummary {
    let subtotal: Decimal
    let taxRate: Double
    let total: Decimal
    
    func displaySubtotal() -> String {
        subtotal.formatted(.currency(code: "USD"))
    }
    
    func displayTax() -> String {
        taxRate.formatted(.percent)
    }
    
    func displayTotal() -> String {
        total.formatted(.currency(code: "USD"))
    }
}

let order = OrderSummary(subtotal: 99.99, taxRate: 0.08, total: 107.99)
print(order.displaySubtotal())  // Locale-aware formatting
print(order.displayTax())       // "8%"
print(order.displayTotal())     // Locale-aware formatting

The compiler ensures type safety—you can't accidentally format a date as currency or vice versa. And the default behaviors match what users expect in their locale without any additional configuration.

Common mistakes

Assuming string.count is O(1): Counting characters requires traversing the entire string. In loops, this becomes O(n²). Cache the count if you need it multiple times.

swift
// Don't do this
for i in 0..

Using string slicing carelessly: Slicing strings creates views, not new strings. If you need an actual String for storage, wrap the slice explicitly: String(text[range]).

Forgetting that regex matching is non-greedy by default: Your regex builder patterns may not match what you expect. Test edge cases thoroughly.

Not testing with international text: A feature that works perfectly for English might break with emoji, diacritics, or right-to-left scripts. Always test with diverse character sets.

Key takeaways

  • Strings are collections of grapheme clusters (Characters), not code units or bytes. This ensures Unicode correctness automatically.
  • String indexes are not integers—design your string logic around this reality and let the compiler catch off-by-one errors.
  • Regex builders provide compile-time validation for pattern matching, making your parsing code type-safe and refactoring-friendly.
  • FormatStyle handles locale-aware formatting for numbers, dates, and currency without boilerplate, adapting automatically to user preferences.
  • String operations like count are O(n), not O(1). Be aware of performance implications in tight loops.
  • Test string handling with real international data—emoji, combining characters, and non-Latin scripts—to catch bugs early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles