Swift Best Practices
Swift's type safety, optionals, and modern concurrency with async/await create robust, safe applications. Features like Result type for explicit error handling, protocols for abstraction, property wrappers for reusable behaviors, and value types for immutability reduce bugs and improve code clarity. Optional chaining prevents nil-pointer crashes, while the strong type system catches errors at compile time rather than runtime.
Overview
This guide covers comprehensive Swift best practices including optionals and optional chaining, protocols and protocol-oriented programming, generics for type-safe abstractions, async/await for structured concurrency, Result type for error handling, property wrappers for reusable logic, value types vs reference types, memory management, and modern Swift idioms.
Core Principles
- Type Safety: Leverage Swift's strong type system
- Optional Handling: Use optional chaining, guard, and if let safely
- Protocols First: Protocol-oriented programming over inheritance
- Value Types: Prefer structs and enums over classes
- Async/Await: Modern concurrency for readable async code
- Result Type: Explicit error handling with Result
- Property Wrappers: Reusable property behaviors
- Immutability: Prefer let over var
- Error Handling: Use throw/try/catch for recoverable errors
- Swift Idioms: Write concise, expressive Swift code
Optionals and Optional Handling
Optionals are Swift's solution to representing the absence of a value. Unlike many languages where any reference can be null (leading to null pointer exceptions), Swift makes the possibility of "no value" explicit in the type system. An optional is essentially an enum with two cases: .some(value) and .none. This forces developers to consciously handle the absence of values, eliminating entire classes of runtime crashes.
The key insight is that optionals move what would be runtime errors (accessing null/nil) into compile-time checks. When you declare String?, you're telling the compiler "this might be a String, or it might be nothing." The compiler then enforces that you handle both cases before accessing the value. See our TypeScript guidelines for similar approaches to handling nullable types in other languages.
Optional Basics
Understanding optional syntax is foundational. The ? suffix creates an optional type, and you must explicitly unwrap optionals before use. Force unwrapping with ! bypasses safety checks and should be avoided except when you have absolute certainty the value exists:
// GOOD: Using optionals safely
struct Payment {
let id: String
let amount: Decimal
let description: String? // Optional - may not have description
func displayDescription() -> String {
return description ?? "No description" // Provide default
}
}
// BAD: Force unwrapping
func processPayment(_ payment: Payment?) {
let amount = payment!.amount // Crashes if payment is nil!
}
// GOOD: Safe unwrapping
func processPayment(_ payment: Payment?) {
guard let payment = payment else {
print("Payment is nil")
return
}
print("Processing payment: \(payment.id)")
}
Optional Chaining
Optional chaining provides a concise way to call properties, methods, and subscripts on optionals that might be nil. When you use optional chaining (the ?. operator), the entire chain short-circuits and returns nil if any link in the chain is nil. This is significantly safer than force unwrapping each step and eliminates deeply nested if-let statements.
The beauty of optional chaining is composability - you can chain multiple optional accesses together, and if any step fails, the whole expression evaluates to nil without crashing. This is particularly useful when navigating object graphs where intermediate objects might not exist:
// GOOD: Optional chaining for safe access
struct Customer {
let name: String
let email: String?
}
struct Payment {
let customer: Customer?
let amount: Decimal
}
func sendReceipt(for payment: Payment) {
// Optional chaining - returns nil if any link is nil
if let email = payment.customer?.email?.lowercased() {
emailService.send(to: email, receipt: generateReceipt())
} else {
print("No email available")
}
}
// GOOD: Chaining multiple optionals
let recipientEmail = payment.recipient?.contact?.primaryEmail?.lowercased()
Guard vs If Let
Choosing between guard and if let is about code flow and readability. guard statements are designed for early returns - they check preconditions and exit early if those preconditions aren't met, keeping the "happy path" at the top level without nesting. In contrast, if let is appropriate when you want to execute code conditionally based on the presence of a value.
The Swift community strongly prefers guard for validation because it reduces cognitive load. Instead of reading deeply nested code, you read a series of guard statements at the top that establish the required state, then the main logic follows in a single block. This pattern, sometimes called "pyramid of doom avoidance," is particularly effective in functions with multiple optional dependencies. For more on validation patterns, see our API patterns guidelines:
// GOOD: Use guard for early returns
func processPayment(_ payment: Payment?) {
guard let payment = payment else {
log.error("Payment is nil")
return
}
guard payment.amount > 0 else {
log.error("Invalid amount")
return
}
// Happy path continues without nesting
validatePayment(payment)
sendToGateway(payment)
}
// BAD: Nested if lets
func processPayment(_ payment: Payment?) {
if let payment = payment {
if payment.amount > 0 {
// Deeply nested
validatePayment(payment)
sendToGateway(payment)
}
}
}
// GOOD: Use if let for conditional execution
func displayPaymentDetails(_ payment: Payment?) {
if let payment = payment {
print("Payment: \(payment.id) for \(payment.amount)")
} else {
print("No payment available")
}
}
Nil Coalescing
// GOOD: Nil coalescing operator for defaults
let description = payment.description ?? "No description provided"
let recipientName = payment.recipient?.name ?? "Unknown"
// GOOD: Chaining nil coalescing
let email = payment.customer?.email ?? payment.customer?.alternateEmail ?? "[email protected]"
Optional Map and FlatMap
// GOOD: Optional map for transformations
func formatAmount(_ payment: Payment?) -> String? {
return payment.map { p in
NumberFormatter.currency.string(from: p.amount as NSDecimalNumber)
}
}
// GOOD: Optional flatMap for nested optionals
func getCustomerEmail(_ payment: Payment?) -> String? {
return payment.flatMap { $0.customer?.email }
}
Protocols and Protocol-Oriented Programming
Protocols define contracts that types must fulfill, similar to interfaces in other languages. However, Swift's protocols are more powerful because they support default implementations through protocol extensions, associated types for generic programming, and can be composed together. Apple describes Swift as a "protocol-oriented programming language," emphasizing protocols over class inheritance.
The shift from inheritance to protocols provides several advantages: value types (structs/enums) can conform to protocols (they can't inherit from classes), you avoid fragile base class problems, and you get horizontal composition instead of vertical inheritance hierarchies. This leads to more flexible, testable code since protocols define minimal contracts that are easy to mock in tests.
Protocol-oriented design particularly shines for dependency injection - instead of depending on concrete types, depend on protocols. This allows swapping implementations (production vs test, different vendors, etc.) without changing consuming code. See our iOS Architecture guidelines for how protocols enable clean architecture patterns.
Protocol Basics
A protocol declares methods, properties, and other requirements. Types that conform to the protocol must provide implementations. Unlike abstract classes, multiple protocols can be adopted, enabling powerful composition:
// GOOD: Protocol for payment processing
protocol PaymentProcessor {
func process(_ payment: Payment) async throws -> PaymentResult
func cancel(_ paymentId: String) async throws -> Bool
}
// Multiple implementations
struct StripePaymentProcessor: PaymentProcessor {
func process(_ payment: Payment) async throws -> PaymentResult {
// Stripe implementation
}
func cancel(_ paymentId: String) async throws -> Bool {
// Stripe cancellation
}
}
struct PayPalPaymentProcessor: PaymentProcessor {
func process(_ payment: Payment) async throws -> PaymentResult {
// PayPal implementation
}
func cancel(_ paymentId: String) async throws -> Bool {
// PayPal cancellation
}
}
Protocol Extensions
Protocol extensions are one of Swift's most powerful features, allowing you to provide default implementations for protocol requirements. This eliminates boilerplate - types get the default behavior for free but can override when needed. More importantly, you can add entirely new methods to protocols that all conforming types automatically inherit.
This creates a powerful pattern: define a minimal protocol contract, then use extensions to build rich APIs on top. Conforming types only implement the essential requirements, getting sophisticated behavior through extensions. This is how Swift's standard library works - Collection has only a handful of required methods, but extensions provide hundreds of derived operations:
// GOOD: Protocol extensions for default implementations
protocol PaymentValidator {
func validate(_ payment: Payment) -> Bool
}
extension PaymentValidator {
// Default implementation
func validate(_ payment: Payment) -> Bool {
return payment.amount > 0 &&
!payment.currency.isEmpty &&
payment.currency.count == 3
}
// Additional helper methods
func isLargePayment(_ payment: Payment) -> Bool {
return payment.amount > 10000
}
}
// Types can use default or override
struct StandardValidator: PaymentValidator {
// Uses default validate implementation
}
struct StrictValidator: PaymentValidator {
// Override with custom validation
func validate(_ payment: Payment) -> Bool {
return payment.amount > 0 &&
payment.amount < 100000 &&
!payment.currency.isEmpty
}
}
Protocol Composition
// GOOD: Composing protocols
protocol Identifiable {
var id: String { get }
}
protocol Timestamped {
var createdAt: Date { get }
var updatedAt: Date? { get }
}
protocol Auditable {
func logEvent(_ event: String)
}
// Combine protocols
typealias AuditableEntity = Identifiable & Timestamped & Auditable
struct Payment: AuditableEntity {
let id: String
let createdAt: Date
var updatedAt: Date?
func logEvent(_ event: String) {
print("Payment \(id): \(event)")
}
}
Associated Types
// GOOD: Generic protocols with associated types
protocol Repository {
associatedtype Entity
func findById(_ id: String) async throws -> Entity?
func findAll() async throws -> [Entity]
func save(_ entity: Entity) async throws
func delete(_ id: String) async throws
}
struct PaymentRepository: Repository {
typealias Entity = Payment
func findById(_ id: String) async throws -> Payment? {
// Implementation
}
func findAll() async throws -> [Payment] {
// Implementation
}
func save(_ entity: Payment) async throws {
// Implementation
}
func delete(_ id: String) async throws {
// Implementation
}
}
Generics
Generics enable you to write flexible, reusable code that works with any type while maintaining type safety. Without generics, you'd either write duplicate code for each type or use Any and lose type safety. Generics give you the best of both worlds: write the code once, and the compiler ensures type correctness at each use site.
Swift's generics are resolved at compile time, meaning there's no runtime performance penalty - the compiler generates specialized code for each concrete type. This is different from languages like Java where generics are erased at runtime. Constraints on generics (like T: Comparable) let you access type-specific functionality while remaining generic.
Understanding generics is essential for leveraging Swift's standard library effectively - Array, Dictionary, Optional, and Result are all generic types. For type-safe API design patterns, see our API guidelines.
Generic Functions
Generic functions use type parameters (conventionally named T, U, V, etc.) that act as placeholders for actual types. When you call a generic function, Swift infers the concrete types from your arguments:
// GOOD: Generic function for any type
func findById<T: Identifiable>(_ id: String, in items: [T]) -> T? {
return items.first { $0.id == id }
}
// Usage
let payments: [Payment] = [...]
let payment = findById("123", in: payments)
let customers: [Customer] = [...]
let customer = findById("456", in: customers)
Generic Types
// GOOD: Generic result wrapper
struct DataResponse<T> {
let data: T
let timestamp: Date
let requestId: String
init(data: T) {
self.data = data
self.timestamp = Date()
self.requestId = UUID().uuidString
}
}
// Usage
let paymentResponse = DataResponse(data: payment)
let customerResponse = DataResponse(data: customer)
Constraints on Generics
// GOOD: Generic with constraints
func sum<T: Numeric>(_ values: [T]) -> T {
return values.reduce(0, +)
}
// GOOD: Multiple constraints
func saveAndLog<T: Codable & Identifiable>(_ entity: T) throws {
let data = try JSONEncoder().encode(entity)
save(data, forKey: entity.id)
log("Saved entity: \(entity.id)")
}
Async/Await and Structured Concurrency
Swift's async/await provides structured concurrency, a paradigm that makes asynchronous code look and behave like synchronous code while maintaining the benefits of non-blocking operations. Before async/await, iOS developers used completion handlers (callbacks), which led to "callback hell" - deeply nested closures that were hard to read, debug, and reason about.
Structured concurrency means async tasks have defined lifetimes and parent-child relationships. When a parent task is cancelled, child tasks are automatically cancelled. When you await multiple operations, you know they'll complete (or be cancelled) before proceeding. This eliminates common concurrency bugs like memory leaks from retained closures or race conditions from uncontrolled task creation.
The await keyword marks suspension points - places where the function yields control, allowing other work to execute. Importantly, an async function doesn't block a thread while waiting; it suspends and the thread can do other work. When the awaited operation completes, execution resumes. This enables highly efficient concurrency without the overhead of thread-per-task models. For related patterns in TypeScript, see our TypeScript general guidelines.
Basic Async/Await
Marking a function async indicates it performs asynchronous work. Calling an async function requires await, making suspension points explicit in the code. The try keyword combines with await when operations can also throw errors:
// GOOD: Async function for network call
func fetchPayments() async throws -> [Payment] {
let url = URL(string: "https://api.bank.com/payments")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Payment].self, from: data)
}
// GOOD: Calling async functions
func loadPayments() async {
do {
let payments = try await fetchPayments()
print("Loaded \(payments.count) payments")
} catch {
print("Failed to load payments: \(error)")
}
}
Async Let for Concurrent Tasks
The async let syntax enables concurrent execution of independent async operations. Unlike sequential await calls where each operation waits for the previous one to complete, async let starts multiple operations immediately, then awaits their results later. This is critical for performance when operations don't depend on each other.
When you write async let result = someAsyncFunction(), the function starts executing immediately in the background. The actual await happens when you access result. This allows multiple operations to overlap in time, dramatically reducing total execution time compared to sequential execution:
// GOOD: Run tasks concurrently
func processPayment(_ payment: Payment) async throws -> PaymentReceipt {
// Start all tasks concurrently
async let fraudCheck = fraudService.check(payment)
async let balanceCheck = accountService.checkBalance(payment)
async let riskScore = riskService.calculate(payment)
// Await all results
let fraud = try await fraudCheck
let balance = try await balanceCheck
let risk = try await riskScore
// Process results
guard fraud.passed && balance.sufficient && risk.acceptable else {
throw PaymentError.validationFailed
}
return try await gateway.process(payment)
}
// BAD: Sequential execution
func processPayment(_ payment: Payment) async throws -> PaymentReceipt {
// Runs sequentially - slow!
let fraud = try await fraudService.check(payment)
let balance = try await accountService.checkBalance(payment)
let risk = try await riskService.calculate(payment)
return try await gateway.process(payment)
}
Task Groups
// GOOD: Process multiple items with TaskGroup
func processPayments(_ payments: [Payment]) async throws -> [PaymentReceipt] {
return try await withThrowingTaskGroup(of: PaymentReceipt.self) { group in
for payment in payments {
group.addTask {
try await self.processPayment(payment)
}
}
var receipts: [PaymentReceipt] = []
for try await receipt in group {
receipts.append(receipt)
}
return receipts
}
}
Main Actor
// GOOD: MainActor for UI updates
@MainActor
class PaymentViewModel: ObservableObject {
@Published var payments: [Payment] = []
@Published var isLoading = false
private let repository: PaymentRepository
init(repository: PaymentRepository) {
self.repository = repository
}
func loadPayments() async {
isLoading = true
defer { isLoading = false }
do {
// Repository call on background thread
let payments = try await repository.getPayments()
// UI update automatically on main thread
self.payments = payments
} catch {
print("Error loading payments: \(error)")
}
}
}
Result Type
Basic Result Usage
// GOOD: Result for explicit error handling
enum PaymentError: Error {
case invalidAmount
case insufficientFunds
case networkError(Error)
}
func processPayment(_ payment: Payment) -> Result<PaymentReceipt, PaymentError> {
guard payment.amount > 0 else {
return .failure(.invalidAmount)
}
do {
let receipt = try gateway.process(payment)
return .success(receipt)
} catch {
return .failure(.networkError(error))
}
}
// Usage
let result = processPayment(payment)
switch result {
case .success(let receipt):
print("Payment successful: \(receipt.transactionId)")
case .failure(let error):
print("Payment failed: \(error)")
}
Result with Async/Await
// GOOD: Async function returning Result
func fetchPayment(id: String) async -> Result<Payment, PaymentError> {
do {
let payment = try await repository.findById(id)
guard let payment = payment else {
return .failure(.notFound)
}
return .success(payment)
} catch {
return .failure(.networkError(error))
}
}
// Usage
let result = await fetchPayment(id: "123")
if case .success(let payment) = result {
print("Found payment: \(payment.id)")
}
Result Transformations
// GOOD: Map and flatMap on Result
let amountResult = processPayment(payment)
.map { receipt in receipt.amount }
let formattedResult = amountResult
.map { amount in formatCurrency(amount) }
// GOOD: FlatMap for chaining Results
func processAndNotify(_ payment: Payment) -> Result<String, PaymentError> {
return processPayment(payment)
.flatMap { receipt in
sendNotification(receipt)
}
}
Property Wrappers
UserDefaults Property Wrapper
// GOOD: Property wrapper for UserDefaults
@propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T
var wrappedValue: T {
get {
return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
// Usage
struct AppSettings {
@UserDefault(key: "is_biometric_enabled", defaultValue: false)
var isBiometricEnabled: Bool
@UserDefault(key: "selected_currency", defaultValue: "USD")
var selectedCurrency: String
}
Clamped Property Wrapper
// GOOD: Property wrapper for value constraints
@propertyWrapper
struct Clamped<Value: Comparable> {
private var value: Value
let range: ClosedRange<Value>
var wrappedValue: Value {
get { value }
set { value = min(max(range.lowerBound, newValue), range.upperBound) }
}
init(wrappedValue: Value, _ range: ClosedRange<Value>) {
self.range = range
self.value = min(max(range.lowerBound, wrappedValue), range.upperBound)
}
}
// Usage
struct PaymentLimit {
@Clamped(0.01...100_000.00)
var amount: Decimal = 1000.00
}
var limit = PaymentLimit()
limit.amount = 200_000 // Clamped to 100_000
print(limit.amount) // 100_000.00
Validated Property Wrapper
// GOOD: Property wrapper with validation
@propertyWrapper
struct Validated<T> {
private var value: T
private let validator: (T) -> Bool
var wrappedValue: T {
get { value }
set {
guard validator(newValue) else {
fatalError("Validation failed for value: \(newValue)")
}
value = newValue
}
}
init(wrappedValue: T, validator: @escaping (T) -> Bool) {
self.validator = validator
guard validator(wrappedValue) else {
fatalError("Initial value validation failed")
}
self.value = wrappedValue
}
}
// Usage
struct Payment {
@Validated(validator: { $0 > 0 })
var amount: Decimal
@Validated(validator: { $0.count == 3 })
var currency: String
}
Value Types vs Reference Types
Swift provides both value types (struct, enum) and reference types (class). Value types are copied when assigned or passed to functions, while reference types share a single instance. This distinction is fundamental to how data flows through your application and affects performance, safety, and concurrency.
Value types are the default choice in Swift because they provide several advantages: no shared mutable state (eliminating entire categories of bugs), automatic thread safety (copies can't affect each other), and clearer ownership semantics. The Swift standard library heavily uses value types - String, Array, Dictionary, Set are all structs. The copy-on-write optimization means copying large collections is actually cheap until you modify them.
Use classes only when you need reference semantics: shared mutable state across multiple owners, Objective-C interoperability, or inheritance hierarchies. For most domain models, structs are superior. See our iOS Architecture guidelines for architectural patterns that leverage value types.
Prefer Value Types
Structs create independent copies on assignment. Modifications to one copy don't affect others, eliminating surprising action-at-a-distance bugs common with shared mutable objects:
// GOOD: Struct for data models
struct Payment {
let id: String
let amount: Decimal
let currency: String
var status: PaymentStatus
}
// Value semantics - copy on assignment
var payment1 = Payment(id: "123", amount: 100, currency: "USD", status: .pending)
var payment2 = payment1 // Creates copy
payment2.status = .completed
print(payment1.status) // pending
print(payment2.status) // completed
// GOOD: Enum for state
enum PaymentStatus {
case pending
case processing
case completed
case failed(reason: String)
}
Use Classes When Needed
// GOOD: Class for shared mutable state
final class PaymentCache {
static let shared = PaymentCache()
private var cache: [String: Payment] = [:]
func get(id: String) -> Payment? {
return cache[id]
}
func set(_ payment: Payment) {
cache[payment.id] = payment
}
func clear() {
cache.removeAll()
}
}
// GOOD: Class for reference semantics
class PaymentViewModel: ObservableObject {
@Published var payments: [Payment] = []
@Published var isLoading = false
// Shared state across views
}
Memory Management
Swift uses Automatic Reference Counting (ARC) to manage memory for reference types (classes). ARC automatically tracks how many strong references exist to a class instance and deallocates the instance when the count reaches zero. Unlike garbage collection, ARC is deterministic - deallocation happens immediately when the last reference disappears, not at some arbitrary future point.
The challenge with reference counting is retain cycles (also called reference cycles or strong reference cycles). When two objects hold strong references to each other, their reference counts never reach zero, causing a memory leak. This is particularly common with closures, which capture strong references to objects from their surrounding context.
Understanding weak and unowned references is essential for breaking retain cycles. A weak reference doesn't increase the reference count and automatically becomes nil when the referenced object is deallocated. An unowned reference also doesn't increase the reference count but assumes the referenced object will outlive the reference (it doesn't become nil). Use weak when the reference may outlive the referenced object; use unowned when you have a guaranteed lifetime relationship.
Strong Reference Cycles
Strong reference cycles occur when objects hold strong references to each other, preventing deallocation. This is most common with closures that capture self:
// BAD: Retain cycle - closure captures self strongly
class PaymentService {
var onComplete: (() -> Void)?
func processPayment() {
self.onComplete = {
self.logCompletion() // Captures self strongly, creating cycle
}
}
func logCompletion() {
print("Payment complete")
}
}
// GOOD: Break cycle with weak self
class PaymentService {
var onComplete: (() -> Void)?
func processPayment() {
self.onComplete = { [weak self] in
self?.logCompletion() // self is now optional, no cycle
}
}
func logCompletion() {
print("Payment complete")
}
}
// GOOD: Use guard for cleaner syntax with weak self
class PaymentService {
var onComplete: (() -> Void)?
func processPayment() {
self.onComplete = { [weak self] in
guard let self = self else { return }
self.logCompletion() // self is now non-optional within scope
self.updateStatus()
}
}
}
Weak vs Unowned References
Choose weak when the referenced object may be deallocated before the reference, and unowned when you know it will outlive the reference:
// GOOD: Weak reference for optional parent
class ViewController: UIViewController {
weak var delegate: PaymentDelegate? // Delegate may be deallocated
func processPayment() {
delegate?.didCompletePayment() // Safe - optional
}
}
// GOOD: Unowned reference for guaranteed lifetime
class Payment {
let customer: Customer // Customer owns Payment
lazy var description: String = { [unowned self] in
// self will always exist because Payment exists
return "Payment for \(self.customer.name)"
}()
}
// BAD: Unowned when object might be deallocated
class PaymentService {
unowned let delegate: PaymentDelegate // Dangerous if delegate deallocated
func processPayment() {
delegate.didCompletePayment() // Crash if delegate is gone!
}
}
Capture Lists in Closures
Capture lists specify how closures capture values from their surrounding context. Use them to break retain cycles and control ownership:
// GOOD: Capture list to break retain cycle
class PaymentViewModel {
let repository: PaymentRepository
var payments: [Payment] = []
func loadPayments() {
repository.fetchPayments { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let payments):
self.payments = payments
case .failure(let error):
print("Error: \(error)")
}
}
}
}
// GOOD: Capture specific values, not self
class PaymentService {
var paymentId: String
func scheduleProcessing() {
let paymentId = self.paymentId // Capture specific value
Timer.scheduledTimer(withTimeInterval: 60, repeats: false) { _ in
// Only captures paymentId string, not self
self.processPayment(id: paymentId)
}
}
}
Memory Management Best Practices
// GOOD: Always use weak self in escaping closures
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
guard let self = self else { return }
// Use self safely
}.resume()
}
// GOOD: Delegate properties should be weak
protocol PaymentServiceDelegate: AnyObject {
func didCompletePayment(_ payment: Payment)
}
class PaymentService {
weak var delegate: PaymentServiceDelegate? // Prevent retain cycle
}
// GOOD: Mark final to enable optimizations
final class PaymentCache { // Cannot be subclassed, enables optimizations
private var cache: [String: Payment] = [:]
}
For related memory management patterns in the context of iOS application architecture, see our iOS Performance guidelines.
Error Handling
Error handling in Swift uses a typed error system where functions explicitly declare they can throw errors using the throws keyword. Unlike exceptions in some languages, Swift errors don't unwind the stack automatically - they must be explicitly handled with do-catch, propagated with try, or converted to optionals with try?.
This approach makes error paths explicit in your code. When you see try, you know this operation might fail. The compiler enforces that you handle or propagate errors, preventing silent failures. For more on error handling patterns in APIs, see our API patterns guidelines.
Throwing Functions
Define custom error types using enums, which can carry associated values to provide context about the failure. This is more powerful than simple error codes or strings:
// GOOD: Define custom errors
enum PaymentError: Error {
case invalidAmount
case insufficientFunds(available: Decimal, requested: Decimal)
case networkError(underlying: Error)
case unauthorized
}
// GOOD: Throwing function
func processPayment(_ payment: Payment) throws -> PaymentReceipt {
guard payment.amount > 0 else {
throw PaymentError.invalidAmount
}
let balance = try checkBalance(for: payment.customerId)
guard balance >= payment.amount else {
throw PaymentError.insufficientFunds(
available: balance,
requested: payment.amount
)
}
return try gateway.process(payment)
}
Do-Catch
// GOOD: Specific error handling
func handlePayment(_ payment: Payment) {
do {
let receipt = try processPayment(payment)
print("Payment successful: \(receipt.transactionId)")
} catch PaymentError.invalidAmount {
print("Invalid payment amount")
} catch PaymentError.insufficientFunds(let available, let requested) {
print("Insufficient funds: \(available) available, \(requested) requested")
} catch {
print("Unexpected error: \(error)")
}
}
Try? and Try!
// GOOD: try? for optional result
let payment = try? processPayment(payment)
if let payment = payment {
print("Payment processed")
}
// GOOD: try? with nil coalescing
let receipt = try? processPayment(payment) ?? defaultReceipt
// BAD: try! force try
let receipt = try! processPayment(payment) // Crashes on error!
// ACCEPTABLE: try! only when guaranteed to succeed
let json = """
{"id": "123", "amount": 100}
"""
let data = json.data(using: .utf8)!
Swift Idioms
Guard for Preconditions
// GOOD: Guard for early returns
func processPayment(_ payment: Payment?) {
guard let payment = payment else { return }
guard payment.amount > 0 else { return }
guard !payment.currency.isEmpty else { return }
// Happy path without nesting
validatePayment(payment)
sendToGateway(payment)
}
Defer for Cleanup
// GOOD: defer for guaranteed cleanup
func processPayment(_ payment: Payment) throws -> PaymentReceipt {
lock.lock()
defer { lock.unlock() } // Always executes
isProcessing = true
defer { isProcessing = false }
return try gateway.process(payment)
}
Computed Properties
// GOOD: Computed properties instead of methods
struct Payment {
let amount: Decimal
let fee: Decimal
var totalAmount: Decimal {
return amount + fee
}
var isLargePayment: Bool {
return amount > 10000
}
}
// Usage
print(payment.totalAmount) // No parentheses
if payment.isLargePayment {
showWarning()
}
Extensions for Organization
// GOOD: Extensions for grouping functionality
extension Payment {
var formattedAmount: String {
return NumberFormatter.currency.string(from: amount as NSDecimalNumber) ?? ""
}
func canBeCancelled() -> Bool {
return status == .pending || status == .scheduled
}
}
// GOOD: Extension for protocol conformance
extension Payment: Equatable {
static func == (lhs: Payment, rhs: Payment) -> Bool {
return lhs.id == rhs.id
}
}
Complete Example
This comprehensive example demonstrates how multiple Swift features work together in a real domain model. It combines protocols (Identifiable, Codable, Equatable), optionals, computed properties, value type semantics, and error handling in a cohesive design:
Comprehensive Domain Model
// GOOD: Complete payment model with Swift features
struct Payment: Identifiable, Codable, Equatable {
let id: String
let customerId: String
let recipientName: String
let recipientAccountNumber: String
let amount: Decimal
let currency: String
var status: PaymentStatus
let description: String?
let reference: String
let createdAt: Date
var completedAt: Date?
// Computed properties
var isCompleted: Bool {
status == .completed
}
var canBeCancelled: Bool {
status == .pending || status == .scheduled
}
var formattedAmount: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currency
return formatter.string(from: amount as NSDecimalNumber) ?? ""
}
// Validation
func validate() throws {
guard amount > 0 else {
throw PaymentError.invalidAmount
}
guard currency.count == 3 else {
throw PaymentError.invalidCurrency
}
guard !recipientAccountNumber.isEmpty else {
throw PaymentError.missingRecipient
}
}
}
enum PaymentStatus: String, Codable {
case pending
case scheduled
case processing
case completed
case failed
case cancelled
}
Further Reading
Internal Documentation
- Swift Testing - Testing strategies with Swift
- iOS Overview - iOS development fundamentals
- iOS UI Development - SwiftUI patterns and navigation
- iOS Data & Networking - URLSession and Core Data
- iOS Security - Keychain and biometric authentication
- iOS Architecture - MVVM and Clean Architecture patterns
- iOS Performance - Performance optimization
External Resources
Summary
Key Takeaways
- Type safety - Leverage Swift's strong type system
- Optionals - Use optional chaining, guard, and if let safely
- Protocols - Protocol-oriented programming for flexibility
- Generics - Type-safe abstractions with constraints
- Async/await - Structured concurrency for readable async code
- Result type - Explicit error handling
- Property wrappers - Reusable property behaviors
- Value types - Prefer structs and enums for immutability
- Error handling - Throwing functions with do-catch
- Swift idioms - Guard, defer, computed properties, extensions
Next Steps: Review Swift Testing for comprehensive testing strategies and iOS Overview for iOS-specific patterns.