Error Handling | Swift - Wyatt's Notes
Representing Errors
Section titled “Representing Errors”Errors in Swift are represented by types conforming to the Error protocol, which is an empty protocol. Any type can be an error.
Enum-Based Errors
Section titled “Enum-Based Errors”enum NetworkError: Error { case invalidURL case noConnection case timeout case serverError(statusCode: Int) case decodingFailed}
enum ValidationError: Error { case emptyField(String) case invalidEmail(String) case passwordTooShort(minLength: Int) case ageOutOfRange(min: Int, max: Int)}
enum FileError: Error { case notFound(path: String) case permissionDenied(path: String) case unreadable}Struct-Based Errors
Section titled “Struct-Based Errors”struct AppError: Error { let code: Int let message: String let underlyingError: Error?
var localizedDescription: String { return "[\(code)] \(message)" }}Throwing Functions
Section titled “Throwing Functions”Functions marked with throws propagate errors to the caller.
func fetchUser(id: Int) throws -> User { guard id > 0 else { throw ValidationError.emptyField("User ID") } // ... network call return User(id: id, name: "Alice")}
func readFile(at path: String) throws -> Data { guard FileManager.default.fileExists(atPath: path) else { throw FileError.notFound(path: path) } return try Data(contentsOf: URL(fileURLWithPath: path))}Typed Throws (Swift 5.9+)
Section titled “Typed Throws (Swift 5.9+)”func parse(input: String) throws(ParseError) -> Int { guard let value = Int(input) else { throw .invalidFormat } return value}
enum ParseError: Error { case invalidFormat case outOfRange}Handling Errors
Section titled “Handling Errors”do-catch
Section titled “do-catch”do { let data = try readFile(at: "/path/to/file.json") let user = try JSONDecoder().decode(User.self, from: data) print("Loaded: \(user.name)")} catch FileError.notFound(let path) { print("File not found: \(path)")} catch FileError.permissionDenied(let path) { print("Permission denied: \(path)")} catch { print("Unexpected error: \(error)")}try? and try
Section titled “try? and try”// try? -- returns nil on error (discards error information)let data = try? readFile(at: "/path/to/file.json")if let data { print("Read \(data.count) bytes")}
// try! -- crashes on error (use only when you are certain it succeeds)let content = try! readFile(at: "/known/good/file.txt")Multiple Catches
Section titled “Multiple Catches”func processFile(path: String) { do { let data = try readFile(at: path) let decoded = try JSONDecoder().decode(Config.self, from: data) apply(config: decoded) } catch FileError.notFound(let path) { print("Missing file: \(path)") } catch FileError.permissionDenied { print("Fix permissions") } catch DecodingError.keyNotFound(let key, _) { print("Missing key in config: \(key.stringValue)") } catch DecodingError.typeMismatch(let type, _) { print("Type mismatch: expected \(type)") } catch is DecodingError { print("General decoding error") } catch { print("Unknown error: \(error)") }}Nested Error Handling
Section titled “Nested Error Handling”func loadConfig() throws -> Config { do { let data = try readFile(at: "/config.json") return try JSONDecoder().decode(Config.self, from: data) } catch let error as DecodingError { print("Decoding failed: \(error)") throw AppError(code: 1001, message: "Config decode failed", underlyingError: error) }}Result Type
Section titled “Result Type”The Result<Success, Failure> enum encapsulates a success value or an error, making error handling explicit and composable.
Basic Usage
Section titled “Basic Usage”func divide(_ a: Double, _ b: Double) -> Result<Double, ArithmeticError> { guard b != 0 else { return .failure(ArithmeticError.divisionByZero) } return .success(a / b)}
let result = divide(10, by: 3)switch result {case .success(let value): print("Result: \(value)")case .failure(let error): print("Error: \(error)")}Result with Higher-Order Functions
Section titled “Result with Higher-Order Functions”let operations: [Result<Double, ArithmeticError>] = [ divide(10, by: 2), // .success(5) divide(10, by: 0), // .failure divide(10, by: 4) // .success(2.5)]
let successes = operations.compactMap { try? $0.get() }// [5.0, 2.5]
let failures = operations.filter { if case .failure = $0 { return true } return false}Converting Between throws and Result
Section titled “Converting Between throws and Result”// From throwing to Resultfunc safeFetchUser(id: Int) -> Result<User, Error> { Result { try fetchUser(id: id) }}
// From Result to throwingfunc unwrap(_ result: Result<Data, Error>) throws -> Data { switch result { case .success(let data): return data case .failure(let error): throw error }}
// Using Result.get()do { let data = try divide(10, by: 3).get() print(data)} catch { print("Error: \(error)")}Chaining Results
Section titled “Chaining Results”extension Result { func map<NewSuccess>(_ transform: (Success) -> NewSuccess) -> Result<NewSuccess, Failure> { switch self { case .success(let value): return .success(transform(value)) case .failure(let error): return .failure(error) } }
func flatMap<NewSuccess>(_ transform: (Success) -> Result<NewSuccess, Failure>) -> Result<NewSuccess, Failure> { switch self { case .success(let value): return transform(value) case .failure(let error): return .failure(error) } }}
let pipeline = divide(10, by: 2) .map { $0 * 3 } // .success(15) .flatMap { divide($0, by: 5) } // .success(3)Error Propagation
Section titled “Error Propagation”Rethrowing Functions
Section titled “Rethrowing Functions”A rethrows function passes through errors from its closure argument.
func withRetry<T>(maxAttempts: Int, operation: () throws -> T) rethrows -> T { var lastError: Error? for attempt in 1...maxAttempts { do { return try operation() } catch { lastError = error print("Attempt \(attempt) failed: \(error)") } } throw lastError! // Guaranteed to exist}
// Caller must handle errors from the operationlet result = try withRetry(maxAttempts: 3) { try networkRequest()}Propagating Errors in Initialisers
Section titled “Propagating Errors in Initialisers”struct Config: Decodable { let apiUrl: URL let timeout: TimeInterval
init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let urlString = try container.decode(String.self, forKey: .apiUrl) guard let url = URL(string: urlString) else { throw DecodingError.dataCorruptedError( forKey: .apiUrl, in: container, debugDescription: "Invalid URL string: \(urlString)" ) } self.apiUrl = url self.timeout = try container.decode(TimeInterval.self, forKey: .timeout) }
enum CodingKeys: String, CodingKey { case apiUrl = "api_url" case timeout }}The defer statement executes cleanup code when the current scope exits, regardless of how it exits (return, throw, break, or normal completion).
Resource Management
Section titled “Resource Management”func processFile(path: String) throws -> String { let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) defer { try? fileHandle.close() }
let data = fileHandle.readDataToEndOfFile() return String(data: data, encoding: .utf8) ?? ""}
func transaction() throws { beginTransaction() defer { if !committed { rollbackTransaction() } }
var committed = false defer { if committed { print("Transaction committed successfully") } }
try executeStatement("INSERT INTO users ...") try executeStatement("UPDATE accounts ...") committed = true}Multiple defer Statements
Section titled “Multiple defer Statements”func multiDefer() { print("Start")
defer { print("Defer 1") } defer { print("Defer 2") } defer { print("Defer 3") }
print("End")}// Output: Start, End, Defer 3, Defer 2, Defer 1// defers execute in reverse order (LIFO)defer with Guard
Section titled “defer with Guard”func connectDatabase(host: String) throws -> Connection { guard !host.isEmpty else { throw ValidationError.emptyField("host") }
let conn = try Connection(host: host) defer { conn.close() }
try conn.authenticate() let data = try conn.query("SELECT * FROM users") return try conn.process(data)}Custom Error Types and Localized Errors
Section titled “Custom Error Types and Localized Errors”enum DatabaseError: Error, LocalizedError { case connectionFailed(host: String) case queryFailed(query: String, reason: String) case timeout(seconds: Double)
var errorDescription: String? { switch self { case .connectionFailed(let host): return "Cannot connect to database at \(host)" case .queryFailed(let query, let reason): return "Query "\(query)' failed: \(reason)" case .timeout(let seconds): return "Database operation timed out after \(seconds)s" } }
var recoverySuggestion: String? { switch self { case .connectionFailed: return "Check network connection and database status" case .queryFailed: return "Verify query syntax and table schema" case .timeout: return "Consider increasing timeout or optimising the query" } }}Error Handling Patterns
Section titled “Error Handling Patterns”Custom Result Type
Section titled “Custom Result Type”enum APIResponse<T> { case success(T) case failure(APIError)
var value: T? { switch self { case .success(let value): return value case .failure: return nil } }
var error: APIError? { switch self { case .success: return nil case .failure(let error): return error } }}
enum APIError: Error { case unauthorized case notFound case serverError(Int) case networkError(Error)}Guard with Result
Section titled “Guard with Result”func validateEmail(_ email: String) -> Result<String, ValidationError> { guard !email.isEmpty else { return .failure(.emptyField("email")) } guard email.contains("@") && email.contains(".") else { return .failure(.invalidEmail(email)) } return .success(email)}
// Usageswitch validateEmail("test@example.com") {case .success(let email): print("Valid: \(email)")case .failure(let error): print("Invalid: \(error)")}Error Handling in SwiftUI
Section titled “Error Handling in SwiftUI”enum LoadError: Error, LocalizedError { case networkUnavailable case serverUnreachable
var errorDescription: String? { switch self { case .networkUnavailable: return "No internet connection" case .serverUnreachable: return "Server is unreachable" } }}
@MainActorclass ViewModel: ObservableObject { @Published var items: [Item] = [] @Published var errorMessage: String? @Published var isLoading = false
func load() async { isLoading = true errorMessage = nil
do { items = try await fetchItems() } catch { errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription }
isLoading = false }}
struct ContentView: View { @StateObject var viewModel = ViewModel()
var body: some View { Group { if let errorMessage = viewModel.errorMessage { Text("Error: \(errorMessage)").foregroundStyle(.red) } else if viewModel.isLoading { ProgressView() } else { List(viewModel.items) { item in Text(item.title) } } } .task { await viewModel.load() } }}Combining Error Handling with Guard
Section titled “Combining Error Handling with Guard”func processData(input: String) throws -> ProcessedResult { guard !input.isEmpty else { throw ValidationError.emptyField("input") }
guard input.count >= 3 else { throw ValidationError.passwordTooShort(minLength: 3) }
let cleaned = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleaned.isEmpty else { throw ValidationError.emptyField("cleaned input") }
let parsed = try parse(cleaned) let validated = try validate(parsed)
return try transform(validated)}Codable Errors
Section titled “Codable Errors”struct User: Codable { let id: Int let name: String let email: String}
func decodeUser(from data: Data) -> Result<User, Error> { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase
do { let user = try decoder.decode(User.self, from: data) return .success(user) } catch let DecodingError.keyNotFound(key, context) { return .failure(AppError( code: 1001, message: "Missing key: \(key.stringValue)", underlyingError: error )) } catch let DecodingError.typeMismatch(type, context) { return .failure(AppError( code: 1002, message: "Type mismatch for \(type)", underlyingError: error )) } catch let DecodingError.valueNotFound(type, context) { return .failure(AppError( code: 1003, message: "Nil value for non-optional type: \(type)", underlyingError: error )) } catch { return .failure(error) }}Intuition
Section titled “Intuition”Error handling in Swift is like a safety protocol in a laboratory. The try/catch/finally pattern ensures that when something goes wrong, you clean up properly and inform someone about the problem. The do/try/catch syntax makes the error path explicit: you must acknowledge that an operation might fail.
Optional chaining is like a series of connected pipes. If any pipe in the chain is broken (nil), the water (value) does not flow through. You do not need to check each pipe individually; the chain handles it for you. This is elegant but means you must decide what to do when the chain breaks.
Worked Examples
Section titled “Worked Examples”Example 1: Nested Result Pipeline with Error Mapping
Section titled “Example 1: Nested Result Pipeline with Error Mapping”Problem: Chain multiple operations that return Result, mapping domain-specific errors to a unified error type at each step.
enum ApiError: Error { case networkFailure(underlying: Error) case decodingFailure(underlying: Error) case validationFailure(field: String, message: String)}
func fetchRaw(url: URL) -> Result<Data, ApiError> { // Simulates network call .failure(.networkFailure(underlying: URLError(.timedOut)))}
func decode<T: Decodable>(_ type: T.Type, from data: Data) -> Result<T, ApiError> { do { return .success(try JSONDecoder().decode(type, from: data)) } catch { return .failure(.decodingFailure(underlying: error)) }}
func validate<T>(_ value: T, _ predicate: (T) -> Bool, field: String, message: String) -> Result<T, ApiError> { predicate(value) ? .success(value) : .failure(.validationFailure(field: field, message: message))}
// Compose the pipelinelet result = fetchRaw(url: URL(string: "https://api.example.com/user")!) .flatMap { decode(User.self, from: $0) } .flatMap { validate($0, { !$0.name.isEmpty }, field: "name", message: "Name required") }
switch result {case .success(let user): print("Valid user: \(user.name)")case .failure(let error): switch error { case .networkFailure: print("Check your connection") case .decodingFailure: print("Server returned invalid data") case .validationFailure(let field, let msg): print("\(field): \(msg)") }}Explanation: Each step returns a Result. flatMap chains steps, short-circuiting on the first failure. Error mapping occurs at the boundary between domain layers: network errors become .networkFailure, JSON errors become .decodingFailure. The final switch handles all error cases exhaustively.
Example 2: Resource Cleanup with Nested defer
Section titled “Example 2: Resource Cleanup with Nested defer”Problem: Read a configuration file, parse it, and apply settings. Ensure the file handle closes, any temporary resources are cleaned up, and a lock is released — regardless of which step fails.
func applyConfiguration(from path: String) throws -> Config { let lock = NSLock() lock.lock()
defer { lock.unlock() print("Lock released") }
let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) defer { fileHandle.closeFile() print("File closed") }
let data = fileHandle.readDataToEndOfFile() let config = try JSONDecoder().decode(Config.self, from: data)
guard config.isValid else { throw ValidationError.emptyField("configuration") }
print("Configuration applied: \(config)") return config}Explanation: defer blocks execute in reverse order (LIFO) when the scope exits. If decode or isValid throws, both the file handle and lock are still cleaned up. The reverse order ensures the lock is released last, maintaining the invariant that resources are freed in the correct sequence.
Example 3: Async Error Recovery with Retry and Fallback
Section titled “Example 3: Async Error Recovery with Retry and Fallback”Problem: Implement a fetch function that retries on transient failures, falls back to a cache on permanent failure, and converts errors to a user-friendly Result.
enum DataError: LocalizedError { case transient(Error) case permanent(Error) case fallbackUsed(Error)
var errorDescription: String? { switch self { case .transient: return "Temporary failure, retrying..." case .permanent(let e): return "Unrecoverable: \(e.localizedDescription)" case .fallbackUsed(let e): return "Using cached data (fresh unavailable: \(e.localizedDescription))" } }}
func fetchWithFallback(id: Int) async -> Result<User, DataError> { var lastError: Error?
for attempt in 1...3 { do { let user = try await fetchUser(id: id) return .success(user) } catch let error as URLError where [.timedOut, .networkConnectionLost].contains(error.code) { lastError = error try? await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt)) * 500_000_000)) continue } catch { return .failure(.permanent(error)) } }
if let cached = try? await loadCachedUser(id: id) { return .failure(.fallbackUsed(lastError!)) }
return .failure(.transient(lastError!))}
// Usagelet result = await fetchWithFallback(id: 42)switch result {case .success(let user): updateUserUI(user)case .failure(let error): showError(error.localizedDescription)}Explanation: The retry loop handles transient network errors with exponential backoff. Non-transient errors fail immediately via .permanent. After exhausting retries, the function attempts a cache fallback, returning .fallbackUsed to indicate degraded data. The Result type makes all three outcomes explicit and composable.
flowchart TD
A[1_Error Handling] --> B[Key Concepts]
A --> C[Core Principles]
A --> D[Practical Applications]
B --> E[Fundamental definitions]
C --> F[Design patterns]
D --> G[Real-world usage]Summary
Section titled “Summary”Swift’s error handling is explicit and type-safe. Enum-based error types provide structured error information. The do/catch/try pattern propagates errors while Result makes error handling composable. defer ensures cleanup code runs reliably, and guard keeps the happy path clean. Combined, these tools enable robust error handling without the overhead of exceptions in other languages.
Common Mistakes
Section titled “Common Mistakes”Using try! without being certain the call will not throw. try! force-unwraps the result and crashes if an error is thrown. Only use try! when you have verified that the function cannot fail in the current context. In most cases, try with do/catch or try? is safer.
Catching too broad an exception type. Writing catch { } without specifying an error type catches everything, including programming errors that should crash. Always catch specific error types: catch NetworkError.timeout { }. This prevents silently swallowing unexpected errors.
Forgetting that defer runs even when an error is thrown. The defer block executes when leaving the current scope, regardless of whether an error occurred. This is useful for cleanup (closing files, releasing locks) but can cause unexpected behaviour if the deferred code has side effects. Place defer at the beginning of the scope for clarity.
Cross-References
Section titled “Cross-References”- Variables and Types - How optionals relate to error handling with try? and optional chaining
- Functions - How throwing functions and rethrows extend the function type system
- Concurrency - How async/await integrates with try/catch for concurrent error handling