Error Handling
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) }}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.
Intuition
Section titled “Intuition”Swift’s error handling is explicit and type-safe. Functions marked with throws can propagate errors, and callers must handle them with do-catch, try?, or try!. The Result type captures success or failure in the return type, making error handling part of the function’s contract rather than an invisible side effect. Typed throws (Swift 5.9+) let you specify exactly which error types a function can produce, enabling the compiler to verify exhaustive error handling.
Cross-References
Section titled “Cross-References”- [[swift/01-basics/1_variables-and-types]] - Optional handling as error absence
- [[swift/02-functions-closures/1_functions]] - Throwing function signatures
- [[swift/03-oop/1_classes-and-structs]] - Error class hierarchies
- [[swift/04-advanced/2_concurrency]] - Error handling in async contexts
Common Mistakes
Section titled “Common Mistakes”Using try! without being certain the operation succeeds: Force-trying crashes your app at runtime if the throwing function actually throws. Only use try! when you have a provable guarantee (e.g., loading a bundled resource that must exist).
Swallowing errors with try? everywhere: Discarding error information with try? makes debugging impossible. Use try? only when the failure case is truly irrelevant; otherwise use do-catch to log or propagate.
Not using defer for cleanup: Forgetting to release resources (file handles, database connections) in defer blocks leads to leaks when errors cause early exits. Always pair resource acquisition with a defer cleanup.